pre-Prod Alpha #18
|
|
@ -17,6 +17,7 @@ from pydantic import BaseModel, Field, model_validator
|
||||||
from db import get_db, get_cursor, r2d
|
from db import get_db, get_cursor, r2d
|
||||||
from club_tenancy import (
|
from club_tenancy import (
|
||||||
assert_valid_governance_visibility,
|
assert_valid_governance_visibility,
|
||||||
|
can_manage_club_org,
|
||||||
club_admin_shares_club_with_creator,
|
club_admin_shares_club_with_creator,
|
||||||
has_club_role,
|
has_club_role,
|
||||||
is_platform_admin,
|
is_platform_admin,
|
||||||
|
|
@ -778,6 +779,10 @@ def bulk_patch_exercises_metadata(
|
||||||
Übungen vollständig durch diese Liste ersetzt (leeres Array entfernt alle).
|
Übungen vollständig durch diese Liste ersetzt (leeres Array entfernt alle).
|
||||||
|
|
||||||
Berechtigt: Ersteller der jeweiligen Übung oder Plattform-Admin (admin/superadmin).
|
Berechtigt: Ersteller der jeweiligen Übung oder Plattform-Admin (admin/superadmin).
|
||||||
|
Zusätzlich: Vereinsorga (club_admin) darf **nur** bei reiner Sichtbarkeitsänderung auf ``club``
|
||||||
|
für den eigenen Verein (`club_id` / aktiver Verein) fremde Übungen freigeben — analog
|
||||||
|
Trainingseinheit-Speichern.
|
||||||
|
|
||||||
Governance wie bei Einzel-PUT (official nur Plattform-Admin; club mit Mitgliedschaft bzw. Admin).
|
Governance wie bei Einzel-PUT (official nur Plattform-Admin; club mit Mitgliedschaft bzw. Admin).
|
||||||
"""
|
"""
|
||||||
profile_id = tenant.profile_id
|
profile_id = tenant.profile_id
|
||||||
|
|
@ -861,14 +866,6 @@ def bulk_patch_exercises_metadata(
|
||||||
owner = rowd.get("created_by")
|
owner = rowd.get("created_by")
|
||||||
if owner is not None:
|
if owner is not None:
|
||||||
owner = int(owner)
|
owner = int(owner)
|
||||||
if owner != profile_id and not is_platform_admin(role):
|
|
||||||
failed.append(
|
|
||||||
{
|
|
||||||
"id": ex_id,
|
|
||||||
"detail": "Keine Berechtigung (nur Ersteller oder Plattform-Admin)",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
ex_vis = (rowd.get("visibility") or "private").strip().lower()
|
ex_vis = (rowd.get("visibility") or "private").strip().lower()
|
||||||
ex_cid_raw = rowd.get("club_id")
|
ex_cid_raw = rowd.get("club_id")
|
||||||
|
|
@ -882,18 +879,45 @@ def bulk_patch_exercises_metadata(
|
||||||
if patch_visibility and body.club_id is not None:
|
if patch_visibility and body.club_id is not None:
|
||||||
next_club = int(body.club_id)
|
next_club = int(body.club_id)
|
||||||
|
|
||||||
|
if patch_visibility and next_vis == "club" and next_club is None:
|
||||||
|
eff = tenant.effective_club_id
|
||||||
|
next_club = int(eff) if eff is not None else None
|
||||||
|
|
||||||
|
if patch_visibility and next_vis == "club" and next_club is None:
|
||||||
|
failed.append(
|
||||||
|
{
|
||||||
|
"id": ex_id,
|
||||||
|
"detail": "Vereins-Übung: club_id angeben oder aktiven Verein wählen (X-Active-Club-Id).",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
other_meta_patches = (
|
||||||
|
patch_status
|
||||||
|
or patch_focus_areas
|
||||||
|
or patch_style_dirs
|
||||||
|
or patch_training_types
|
||||||
|
or patch_target_groups
|
||||||
|
)
|
||||||
|
is_owner_or_platform = owner == profile_id or is_platform_admin(role)
|
||||||
|
if not is_owner_or_platform:
|
||||||
|
org_club_promo_only = (
|
||||||
|
patch_visibility
|
||||||
|
and not other_meta_patches
|
||||||
|
and next_vis == "club"
|
||||||
|
and next_club is not None
|
||||||
|
and can_manage_club_org(cur, profile_id, int(next_club), role)
|
||||||
|
)
|
||||||
|
if not org_club_promo_only:
|
||||||
|
failed.append(
|
||||||
|
{
|
||||||
|
"id": ex_id,
|
||||||
|
"detail": "Keine Berechtigung (Ersteller, Plattform-Admin oder Vereinsorga bei reiner Vereinsfreigabe).",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
if patch_visibility:
|
if patch_visibility:
|
||||||
if next_vis == "club":
|
|
||||||
if next_club is None:
|
|
||||||
next_club = tenant.effective_club_id
|
|
||||||
if next_club is None:
|
|
||||||
failed.append(
|
|
||||||
{
|
|
||||||
"id": ex_id,
|
|
||||||
"detail": "Vereins-Übung: club_id angeben oder aktiven Verein wählen (X-Active-Club-Id).",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
gov_club = next_club if next_vis == "club" else None
|
gov_club = next_club if next_vis == "club" else None
|
||||||
try:
|
try:
|
||||||
assert_valid_governance_visibility(cur, profile_id, role, next_vis, gov_club)
|
assert_valid_governance_visibility(cur, profile_id, role, next_vis, gov_club)
|
||||||
|
|
|
||||||
|
|
@ -653,6 +653,109 @@ def _replace_unit_sections(cur, unit_id: int, sections_in: List[Any]):
|
||||||
_insert_section_items(cur, sid, sec.get("items"))
|
_insert_section_items(cur, sid, sec.get("items"))
|
||||||
|
|
||||||
|
|
||||||
|
def _distinct_exercise_ids_in_unit(cur, unit_id: int) -> List[int]:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT DISTINCT tusi.exercise_id
|
||||||
|
FROM training_unit_section_items tusi
|
||||||
|
INNER JOIN training_unit_sections tus ON tusi.section_id = tus.id
|
||||||
|
WHERE tus.training_unit_id = %s
|
||||||
|
AND tusi.item_type = 'exercise'
|
||||||
|
AND tusi.exercise_id IS NOT NULL
|
||||||
|
""",
|
||||||
|
(unit_id,),
|
||||||
|
)
|
||||||
|
rows = cur.fetchall() or []
|
||||||
|
out: List[int] = []
|
||||||
|
for r in rows:
|
||||||
|
try:
|
||||||
|
out.append(int(r["exercise_id"]))
|
||||||
|
except (TypeError, ValueError, KeyError):
|
||||||
|
continue
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _group_club_id_for_scheduled_unit(cur, unit_id: int) -> Optional[int]:
|
||||||
|
"""Nur echte Gruppentermine (keine Rahmen-Blueprints ohne Gruppe)."""
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT tg.club_id
|
||||||
|
FROM training_units tu
|
||||||
|
INNER JOIN training_groups tg ON tu.group_id = tg.id
|
||||||
|
WHERE tu.id = %s AND tu.framework_slot_id IS NULL
|
||||||
|
""",
|
||||||
|
(unit_id,),
|
||||||
|
)
|
||||||
|
r = cur.fetchone()
|
||||||
|
if not r or r.get("club_id") is None:
|
||||||
|
return None
|
||||||
|
return int(r["club_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def _caller_may_promote_exercise_to_club(
|
||||||
|
cur,
|
||||||
|
exercise_created_by: Optional[int],
|
||||||
|
profile_id: int,
|
||||||
|
role: str,
|
||||||
|
target_club_id: int,
|
||||||
|
) -> bool:
|
||||||
|
if is_platform_admin(role):
|
||||||
|
return True
|
||||||
|
if exercise_created_by is not None and int(exercise_created_by) == profile_id:
|
||||||
|
return True
|
||||||
|
if can_manage_club_org(cur, profile_id, target_club_id, role):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _promote_private_exercises_used_in_unit(cur, unit_id: int, profile_id: int, role: str) -> None:
|
||||||
|
"""
|
||||||
|
Private Übungen in der Einheit auf visibility=club (Verein der Trainingsgruppe) setzen,
|
||||||
|
damit andere Trainer und Mitglieder sie in der Durchführung sehen.
|
||||||
|
"""
|
||||||
|
target_club_id = _group_club_id_for_scheduled_unit(cur, unit_id)
|
||||||
|
if not target_club_id:
|
||||||
|
return
|
||||||
|
if not (
|
||||||
|
is_platform_admin(role)
|
||||||
|
or _profile_active_in_club(cur, target_club_id, profile_id)
|
||||||
|
or can_manage_club_org(cur, profile_id, target_club_id, role)
|
||||||
|
):
|
||||||
|
return
|
||||||
|
|
||||||
|
for eid in _distinct_exercise_ids_in_unit(cur, unit_id):
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, created_by, visibility, club_id, COALESCE(status, '') AS status
|
||||||
|
FROM exercises WHERE id = %s
|
||||||
|
""",
|
||||||
|
(eid,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
continue
|
||||||
|
if str(row.get("status") or "").strip().lower() == "archived":
|
||||||
|
continue
|
||||||
|
vis = (row.get("visibility") or "private").strip().lower()
|
||||||
|
if vis == "official":
|
||||||
|
continue
|
||||||
|
if vis == "club":
|
||||||
|
continue
|
||||||
|
if vis != "private":
|
||||||
|
continue
|
||||||
|
cb = row.get("created_by")
|
||||||
|
if not _caller_may_promote_exercise_to_club(cur, cb, profile_id, role, target_club_id):
|
||||||
|
continue
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE exercises
|
||||||
|
SET visibility = 'club', club_id = %s, updated_at = NOW()
|
||||||
|
WHERE id = %s AND LOWER(COALESCE(visibility, 'private')) = 'private'
|
||||||
|
""",
|
||||||
|
(target_club_id, eid),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _insert_sections_from_legacy_exercises(cur, unit_id: int, exercises_in: List[Any]):
|
def _insert_sections_from_legacy_exercises(cur, unit_id: int, exercises_in: List[Any]):
|
||||||
if not exercises_in:
|
if not exercises_in:
|
||||||
return
|
return
|
||||||
|
|
@ -1281,6 +1384,8 @@ def create_training_unit(data: dict, tenant: TenantContext = Depends(get_tenant_
|
||||||
elif exercises_in is not None:
|
elif exercises_in is not None:
|
||||||
_insert_sections_from_legacy_exercises(cur, unit_id, exercises_in)
|
_insert_sections_from_legacy_exercises(cur, unit_id, exercises_in)
|
||||||
|
|
||||||
|
_promote_private_exercises_used_in_unit(cur, unit_id, profile_id, role)
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
return get_training_unit(unit_id, tenant)
|
return get_training_unit(unit_id, tenant)
|
||||||
|
|
@ -1461,6 +1566,9 @@ def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depen
|
||||||
cur.execute("DELETE FROM training_unit_sections WHERE training_unit_id = %s", (unit_id,))
|
cur.execute("DELETE FROM training_unit_sections WHERE training_unit_id = %s", (unit_id,))
|
||||||
_insert_sections_from_legacy_exercises(cur, unit_id, data["exercises"] or [])
|
_insert_sections_from_legacy_exercises(cur, unit_id, data["exercises"] or [])
|
||||||
|
|
||||||
|
if content_handled or "sections" in data or "exercises" in data:
|
||||||
|
_promote_private_exercises_used_in_unit(cur, unit_id, profile_id, role)
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
return get_training_unit(unit_id, tenant)
|
return get_training_unit(unit_id, tenant)
|
||||||
|
|
@ -1572,6 +1680,8 @@ def create_training_unit_from_framework_slot(data: dict, tenant: TenantContext =
|
||||||
slot_id,
|
slot_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_promote_private_exercises_used_in_unit(cur, new_id, profile_id, role)
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
return get_training_unit(new_id, tenant)
|
return get_training_unit(new_id, tenant)
|
||||||
|
|
@ -1644,6 +1754,7 @@ def quick_create_training_unit(data: dict, tenant: TenantContext = Depends(get_t
|
||||||
|
|
||||||
if tpl_id_safe:
|
if tpl_id_safe:
|
||||||
_instantiate_from_template(cur, unit_id, tpl_id_safe)
|
_instantiate_from_template(cur, unit_id, tpl_id_safe)
|
||||||
|
_promote_private_exercises_used_in_unit(cur, unit_id, profile_id, role)
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
|
|
||||||
280
frontend/src/components/TrainingPlanExerciseVisibilityPanel.jsx
Normal file
280
frontend/src/components/TrainingPlanExerciseVisibilityPanel.jsx
Normal file
|
|
@ -0,0 +1,280 @@
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import { useAuth } from '../context/AuthContext'
|
||||||
import ExercisePickerModal from '../components/ExercisePickerModal'
|
import ExercisePickerModal from '../components/ExercisePickerModal'
|
||||||
import ExercisePeekModal from '../components/ExercisePeekModal'
|
import ExercisePeekModal from '../components/ExercisePeekModal'
|
||||||
import TrainingUnitSectionsEditor from '../components/TrainingUnitSectionsEditor'
|
import TrainingUnitSectionsEditor from '../components/TrainingUnitSectionsEditor'
|
||||||
|
import TrainingPlanExerciseVisibilityPanel from '../components/TrainingPlanExerciseVisibilityPanel'
|
||||||
import PageSectionNav from '../components/PageSectionNav'
|
import PageSectionNav from '../components/PageSectionNav'
|
||||||
import {
|
import {
|
||||||
defaultSection,
|
defaultSection,
|
||||||
|
|
@ -175,6 +176,22 @@ function TrainingPlanningPage() {
|
||||||
sections: [defaultSection()],
|
sections: [defaultSection()],
|
||||||
...sessionAssignDefaults()
|
...sessionAssignDefaults()
|
||||||
})
|
})
|
||||||
|
const planningFormRef = useRef(formData)
|
||||||
|
planningFormRef.current = formData
|
||||||
|
|
||||||
|
const planningModalClubId = useMemo(() => {
|
||||||
|
const gid = Number(formData.group_id)
|
||||||
|
if (!Number.isFinite(gid) || gid < 1) return null
|
||||||
|
const g = groups.find((x) => Number(x.id) === gid)
|
||||||
|
if (!g || g.club_id == null || g.club_id === '') return null
|
||||||
|
const c = Number(g.club_id)
|
||||||
|
return Number.isFinite(c) ? c : null
|
||||||
|
}, [groups, formData.group_id])
|
||||||
|
|
||||||
|
const refreshPlanningSectionMeta = useCallback(async () => {
|
||||||
|
const next = await enrichSectionsWithVariants(planningFormRef.current.sections)
|
||||||
|
setFormData((prev) => ({ ...prev, sections: next }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
const loadPlanTemplates = useCallback(async () => {
|
const loadPlanTemplates = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -2204,6 +2221,13 @@ function TrainingPlanningPage() {
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<TrainingPlanExerciseVisibilityPanel
|
||||||
|
sections={formData.sections}
|
||||||
|
targetClubId={planningModalClubId}
|
||||||
|
user={user}
|
||||||
|
onMetaRefresh={refreshPlanningSectionMeta}
|
||||||
|
/>
|
||||||
|
|
||||||
<div style={{ marginTop: '2rem' }}>
|
<div style={{ marginTop: '2rem' }}>
|
||||||
{editingUnit ? (
|
{editingUnit ? (
|
||||||
<div style={{ marginBottom: '1rem' }}>
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
|
|
|
||||||
|
|
@ -23,20 +23,48 @@ export async function hydrateExercisePlanningRow(exercise) {
|
||||||
let title = exercise?.title || ''
|
let title = exercise?.title || ''
|
||||||
const id = exercise?.id
|
const id = exercise?.id
|
||||||
if (!id) return null
|
if (!id) return null
|
||||||
|
let meta = {}
|
||||||
if (!variants.length) {
|
if (!variants.length) {
|
||||||
try {
|
try {
|
||||||
const full = await api.getExercise(id)
|
const full = await api.getExercise(id)
|
||||||
variants = Array.isArray(full?.variants) ? full.variants : []
|
variants = Array.isArray(full?.variants) ? full.variants : []
|
||||||
title = full?.title || title
|
title = full?.title || title
|
||||||
|
meta = {
|
||||||
|
exercise_visibility: full?.visibility || 'private',
|
||||||
|
exercise_club_id: full?.club_id ?? null,
|
||||||
|
exercise_created_by: full?.created_by ?? null,
|
||||||
|
exercise_status: full?.status || 'draft',
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
variants = []
|
variants = []
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
meta = {
|
||||||
|
exercise_visibility: exercise?.visibility ?? null,
|
||||||
|
exercise_club_id: exercise?.club_id ?? null,
|
||||||
|
exercise_created_by: exercise?.created_by ?? null,
|
||||||
|
exercise_status: exercise?.status ?? null,
|
||||||
|
}
|
||||||
|
if (meta.exercise_visibility == null || meta.exercise_created_by == null) {
|
||||||
|
try {
|
||||||
|
const full = await api.getExercise(id)
|
||||||
|
if (meta.exercise_visibility == null) meta.exercise_visibility = full?.visibility || 'private'
|
||||||
|
if (meta.exercise_club_id == null) meta.exercise_club_id = full?.club_id ?? null
|
||||||
|
if (meta.exercise_created_by == null) meta.exercise_created_by = full?.created_by ?? null
|
||||||
|
if (meta.exercise_status == null) meta.exercise_status = full?.status || 'draft'
|
||||||
|
} catch {
|
||||||
|
/* keep partial meta */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
meta.exercise_visibility = meta.exercise_visibility || 'private'
|
||||||
|
meta.exercise_status = meta.exercise_status || 'draft'
|
||||||
}
|
}
|
||||||
const row = exerciseRow()
|
const row = exerciseRow()
|
||||||
row.exercise_id = id
|
row.exercise_id = id
|
||||||
row.exercise_variant_id = ''
|
row.exercise_variant_id = ''
|
||||||
row.exercise_title = title
|
row.exercise_title = title
|
||||||
row.variants = variants
|
row.variants = variants
|
||||||
|
Object.assign(row, meta)
|
||||||
return row
|
return row
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -119,9 +147,20 @@ export async function enrichSectionsWithVariants(sections) {
|
||||||
cache.set(id, {
|
cache.set(id, {
|
||||||
title: ex.title || '',
|
title: ex.title || '',
|
||||||
variants: Array.isArray(ex.variants) ? ex.variants : [],
|
variants: Array.isArray(ex.variants) ? ex.variants : [],
|
||||||
|
visibility: ex.visibility || 'private',
|
||||||
|
club_id: ex.club_id ?? null,
|
||||||
|
created_by: ex.created_by ?? null,
|
||||||
|
status: ex.status || 'draft',
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
cache.set(id, { title: '', variants: [] })
|
cache.set(id, {
|
||||||
|
title: '',
|
||||||
|
variants: [],
|
||||||
|
visibility: 'private',
|
||||||
|
club_id: null,
|
||||||
|
created_by: null,
|
||||||
|
status: 'draft',
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
@ -137,6 +176,10 @@ export async function enrichSectionsWithVariants(sections) {
|
||||||
exercise_title: it.exercise_title || c.title,
|
exercise_title: it.exercise_title || c.title,
|
||||||
variants:
|
variants:
|
||||||
Array.isArray(it.variants) && it.variants.length > 0 ? it.variants : c.variants,
|
Array.isArray(it.variants) && it.variants.length > 0 ? it.variants : c.variants,
|
||||||
|
exercise_visibility: c.visibility,
|
||||||
|
exercise_club_id: c.club_id,
|
||||||
|
exercise_created_by: c.created_by,
|
||||||
|
exercise_status: c.status,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user