All checks were successful
Deploy Development / deploy (push) Successful in 33s
Test Suite / pytest-backend (push) Successful in 6s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Successful in 45s
- Added functionality to promote private exercises used in training units to club visibility, allowing better access for trainers and members. - Introduced helper functions to retrieve distinct exercise IDs and group club IDs for scheduled units. - Updated the create, update, and quick create training unit methods to include exercise promotion logic, enhancing exercise management within clubs.
281 lines
9.4 KiB
JavaScript
281 lines
9.4 KiB
JavaScript
import React, { useCallback, useMemo, useState } from 'react'
|
|
import { Link } from 'react-router-dom'
|
|
import api from '../utils/api'
|
|
|
|
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 = (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>
|
|
)
|
|
}
|