All checks were successful
Deploy Development / deploy (push) Successful in 40s
Test Suite / pytest-backend (push) Successful in 25s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Successful in 23s
- Introduced a new utility function to filter and return only active club memberships, improving role management and access control. - Updated various components and pages to utilize the new active club memberships function, ensuring only relevant memberships are considered. - Enhanced user interface elements to reflect the status of club memberships, including visual indicators for inactive memberships. - Improved backend logic for resolving tenant contexts and managing club roles based on active memberships.
282 lines
9.5 KiB
JavaScript
282 lines
9.5 KiB
JavaScript
import React, { useCallback, useMemo, useState } from 'react'
|
|
import { Link } from 'react-router-dom'
|
|
import api from '../utils/api'
|
|
import { activeClubMemberships } from '../utils/activeClub'
|
|
|
|
const VIS_LABELS = {
|
|
private: 'Privat',
|
|
club: 'Verein',
|
|
official: 'Offiziell',
|
|
}
|
|
|
|
function collectExerciseRows(sections) {
|
|
const map = new Map()
|
|
for (const sec of sections || []) {
|
|
for (const it of sec.items || []) {
|
|
if (it.item_type === 'note') continue
|
|
const id = Number(it.exercise_id)
|
|
if (!Number.isFinite(id) || id < 1) continue
|
|
if (!map.has(id)) {
|
|
map.set(id, it)
|
|
}
|
|
}
|
|
}
|
|
return [...map.entries()].map(([id, it]) => ({
|
|
id,
|
|
title: it.exercise_title || `Übung #${id}`,
|
|
visibility: it.exercise_visibility,
|
|
clubId: it.exercise_club_id != null ? Number(it.exercise_club_id) : null,
|
|
createdBy: it.exercise_created_by != null ? Number(it.exercise_created_by) : null,
|
|
status: it.exercise_status,
|
|
}))
|
|
}
|
|
|
|
function needsClubForTarget(row, targetClubId) {
|
|
if (targetClubId == null || !Number.isFinite(Number(targetClubId))) return false
|
|
const vis = String(row.visibility || 'private').toLowerCase()
|
|
if (vis === 'official') return false
|
|
const tc = Number(targetClubId)
|
|
if (vis === 'private') return true
|
|
if (vis === 'club') {
|
|
if (row.clubId == null) return true
|
|
return row.clubId !== tc
|
|
}
|
|
return false
|
|
}
|
|
|
|
function userMayPromote(user, targetClubId, createdBy) {
|
|
if (!user || targetClubId == null) return false
|
|
const role = String(user.role || '').toLowerCase()
|
|
if (role === 'admin' || role === 'superadmin') return true
|
|
if (createdBy != null && Number(createdBy) === Number(user.id)) return true
|
|
const row = activeClubMemberships(user?.clubs).find((c) => Number(c.id) === Number(targetClubId))
|
|
if (!row || !Array.isArray(row.roles)) return false
|
|
return row.roles.includes('club_admin')
|
|
}
|
|
|
|
/**
|
|
* Listen-Panel im Trainingsplan: Übungen, die für die gewählte Gruppe noch nicht vereinsweit sichtbar sind,
|
|
* und Freigabe auf „Verein“ (API: PUT / bulk-metadata).
|
|
*/
|
|
export default function TrainingPlanExerciseVisibilityPanel({
|
|
sections,
|
|
targetClubId,
|
|
user,
|
|
onMetaRefresh,
|
|
}) {
|
|
const [busyId, setBusyId] = useState(null)
|
|
const [bulkBusy, setBulkBusy] = useState(false)
|
|
const [message, setMessage] = useState(null)
|
|
|
|
const rows = useMemo(() => collectExerciseRows(sections), [sections])
|
|
|
|
const { pending, okCount } = useMemo(() => {
|
|
if (targetClubId == null || !Number.isFinite(Number(targetClubId))) {
|
|
return { pending: [], okCount: 0 }
|
|
}
|
|
const pending = []
|
|
let okCount = 0
|
|
for (const r of rows) {
|
|
if (needsClubForTarget(r, targetClubId)) pending.push(r)
|
|
else okCount += 1
|
|
}
|
|
return { pending, okCount }
|
|
}, [rows, targetClubId])
|
|
|
|
const promotableIds = useMemo(
|
|
() => pending.filter((r) => userMayPromote(user, targetClubId, r.createdBy)).map((r) => r.id),
|
|
[pending, targetClubId, user]
|
|
)
|
|
|
|
const applyClubVisibility = useCallback(
|
|
async (exerciseIds) => {
|
|
if (!exerciseIds.length || targetClubId == null) return
|
|
setMessage(null)
|
|
const res = await api.bulkPatchExercisesMetadata({
|
|
exercise_ids: exerciseIds,
|
|
visibility: 'club',
|
|
club_id: targetClubId,
|
|
})
|
|
const failed = res?.failed || []
|
|
const updatedN = Number(res?.updated_count || 0)
|
|
if (updatedN > 0 && onMetaRefresh) {
|
|
await onMetaRefresh()
|
|
}
|
|
if (failed.length) {
|
|
const first = failed[0]?.detail || 'Unbekannter Fehler'
|
|
setMessage(
|
|
failed.length === 1
|
|
? String(first)
|
|
: `${failed.length} Übungen nicht geändert: ${first}`
|
|
)
|
|
}
|
|
},
|
|
[targetClubId, onMetaRefresh]
|
|
)
|
|
|
|
const onPromoteOne = useCallback(
|
|
async (id) => {
|
|
setBusyId(id)
|
|
setMessage(null)
|
|
try {
|
|
await applyClubVisibility([id])
|
|
} catch (e) {
|
|
setMessage(e?.message || String(e))
|
|
} finally {
|
|
setBusyId(null)
|
|
}
|
|
},
|
|
[applyClubVisibility]
|
|
)
|
|
|
|
const onPromoteAll = useCallback(async () => {
|
|
if (!promotableIds.length) return
|
|
setBulkBusy(true)
|
|
setMessage(null)
|
|
try {
|
|
await applyClubVisibility(promotableIds)
|
|
} catch (e) {
|
|
setMessage(e?.message || String(e))
|
|
} finally {
|
|
setBulkBusy(false)
|
|
}
|
|
}, [applyClubVisibility, promotableIds])
|
|
|
|
if (!rows.length) return null
|
|
|
|
return (
|
|
<div
|
|
className="training-planning-template-panel training-plan-visibility-panel no-print"
|
|
style={{ marginBottom: '1.15rem' }}
|
|
>
|
|
<span className="training-planning-template-panel__label">Sichtbarkeit für den Verein</span>
|
|
<p className="training-planning-template-panel__help" style={{ marginTop: 0 }}>
|
|
Übungen mit Sichtbarkeit „Privat“ oder einem anderen Verein sieht das Team bei der Durchführung
|
|
nicht. Hier können Sie sie auf <strong>Verein</strong> setzen (gleiche Logik wie beim Speichern der
|
|
Einheit).
|
|
</p>
|
|
{targetClubId == null || !Number.isFinite(Number(targetClubId)) ? (
|
|
<p style={{ margin: '0.35rem 0 0', fontSize: '0.82rem', color: 'var(--text3)', lineHeight: 1.45 }}>
|
|
Wählen Sie eine Trainingsgruppe, um passende Freigaben anzuzeigen.
|
|
</p>
|
|
) : null}
|
|
{targetClubId != null && Number.isFinite(Number(targetClubId)) && !pending.length && rows.length ? (
|
|
<p
|
|
style={{
|
|
margin: '0.5rem 0 0',
|
|
fontSize: '0.88rem',
|
|
color: 'var(--text2)',
|
|
lineHeight: 1.45,
|
|
}}
|
|
>
|
|
Alle {rows.length} {rows.length === 1 ? 'Übung ist' : 'Übungen sind'} für diesen Verein in der
|
|
Durchführung sichtbar (oder offiziell).
|
|
</p>
|
|
) : null}
|
|
{targetClubId != null && Number.isFinite(Number(targetClubId)) && pending.length ? (
|
|
<>
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
flexWrap: 'wrap',
|
|
alignItems: 'center',
|
|
gap: '10px',
|
|
marginTop: '0.65rem',
|
|
}}
|
|
>
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary"
|
|
disabled={!promotableIds.length || bulkBusy}
|
|
onClick={onPromoteAll}
|
|
>
|
|
{bulkBusy ? 'Speichern…' : `Alle auf Verein (${promotableIds.length})`}
|
|
</button>
|
|
{okCount > 0 ? (
|
|
<span style={{ fontSize: '0.82rem', color: 'var(--text3)' }}>
|
|
{okCount} weitere {okCount === 1 ? 'Übung' : 'Übungen'} bereits passend
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
<ul
|
|
style={{
|
|
listStyle: 'none',
|
|
margin: '0.75rem 0 0',
|
|
padding: 0,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: '8px',
|
|
}}
|
|
>
|
|
{pending.map((r) => {
|
|
const vis = String(r.visibility || 'private').toLowerCase()
|
|
const visLabel = VIS_LABELS[vis] || vis
|
|
const may = userMayPromote(user, targetClubId, r.createdBy)
|
|
const loading = busyId === r.id
|
|
return (
|
|
<li
|
|
key={r.id}
|
|
style={{
|
|
display: 'flex',
|
|
flexWrap: 'wrap',
|
|
alignItems: 'center',
|
|
gap: '10px',
|
|
padding: '10px 12px',
|
|
borderRadius: '10px',
|
|
border: '1px solid var(--border2)',
|
|
background: 'var(--surface)',
|
|
}}
|
|
>
|
|
<div style={{ flex: '1 1 160px', minWidth: 0 }}>
|
|
<Link to={`/exercises/${r.id}`} style={{ fontWeight: 600, fontSize: '0.9rem' }}>
|
|
{r.title}
|
|
</Link>
|
|
<div style={{ fontSize: '0.78rem', color: 'var(--text3)', marginTop: '4px' }}>
|
|
Aktuell: {visLabel}
|
|
{vis === 'club' && r.clubId != null && r.clubId !== Number(targetClubId)
|
|
? ` · anderer Verein (#${r.clubId})`
|
|
: ''}
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary"
|
|
disabled={!may || loading || bulkBusy}
|
|
title={
|
|
may
|
|
? 'Sichtbarkeit auf Verein setzen'
|
|
: 'Nur Ersteller, Plattform-Admin oder Vereinsorga (Admin im Verein)'
|
|
}
|
|
onClick={() => onPromoteOne(r.id)}
|
|
>
|
|
{loading ? '…' : 'Auf Verein'}
|
|
</button>
|
|
</li>
|
|
)
|
|
})}
|
|
</ul>
|
|
{pending.some((r) => !userMayPromote(user, targetClubId, r.createdBy)) ? (
|
|
<p className="training-planning-template-panel__help" style={{ marginTop: '0.65rem' }}>
|
|
Einige Einträge können Sie nicht selbst freigeben: Denken Sie an die Vereinsorga oder speichern Sie
|
|
die Einheit — bei ausreichender Berechtigung werden private Übungen dann automatisch mitgeführt.
|
|
</p>
|
|
) : null}
|
|
</>
|
|
) : null}
|
|
{message ? (
|
|
<p
|
|
role="alert"
|
|
style={{
|
|
margin: '0.65rem 0 0',
|
|
fontSize: '0.84rem',
|
|
color: 'var(--danger, #c0392b)',
|
|
lineHeight: 1.45,
|
|
}}
|
|
>
|
|
{message}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
)
|
|
}
|