feat: implement bulk metadata update for exercises
- Introduced a new PATCH endpoint `/api/exercises/bulk-metadata` to allow bulk updates of visibility and status for exercises, supporting up to 500 IDs. - Enhanced the ExercisesListPage to include a bulk update modal for managing exercise visibility and status. - Updated frontend API utility to handle bulk patch requests. - Bumped application version to 0.8.29 and updated changelog to reflect these changes.
This commit is contained in:
parent
e0ecfe927f
commit
dc310b38eb
|
|
@ -9,7 +9,8 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C.
|
|||
| clubs | geschützte `/api/clubs*`, `/divisions*`, `/groups*` | ja | `get_tenant_context` | Mitgliedschaft / `can_manage_*` | Öffentlich: `/clubs/public-directory` ohne Auth |
|
||||
| club_memberships | `/clubs/{id}/members*` | ja | `get_tenant_context` | ja | |
|
||||
| club_join_requests | `/me/club-join-requests`, `/clubs/{id}/join-requests*` | ja | `get_tenant_context` | ja | |
|
||||
| exercises | alle geschützten `/api/exercises*` | ja | `get_tenant_context` | ja | |
|
||||
| exercises | `PATCH /api/exercises/bulk-metadata` | ja | `get_tenant_context` | ja | Liste: UI-Mehrfachwahl; bis 500 IDs; nur Ersteller oder Plattform-Admin |
|
||||
| exercises | übrige geschützte `/api/exercises*` | ja | `get_tenant_context` | ja | |
|
||||
| exercise_progression_graphs | `/api/exercise-progression-graphs*` | ja | `get_tenant_context` | Liste wie Bibliothek; Schreiben Ersteller/Plattform-Admin | Kanten: Lesen wenn Graph lesbar |
|
||||
| training_planning | alle geschützten Endpoints | ja | `get_tenant_context` | ja | Vorlagen-Liste wie Übungen; POST Vorlage Default club_id |
|
||||
| training_framework_programs | alle geschützten Endpoints | ja | `get_tenant_context` | ja | Liste + POST Default club_id |
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import json
|
|||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query, UploadFile, File, Form
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
|
@ -213,6 +213,25 @@ class ExerciseVariantsReorder(BaseModel):
|
|||
variant_ids: list[int]
|
||||
|
||||
|
||||
_VALID_EXERCISE_STATUS_BULK = frozenset({"draft", "in_review", "approved", "archived"})
|
||||
_MAX_BULK_METADATA_IDS = 500
|
||||
|
||||
|
||||
class ExerciseBulkMetadataPatch(BaseModel):
|
||||
"""Massenänderung von Sichtbarkeit und/oder Status (z. B. Private → Verein)."""
|
||||
|
||||
exercise_ids: list[int] = Field(..., min_length=1, max_length=_MAX_BULK_METADATA_IDS)
|
||||
visibility: Optional[str] = Field(None, pattern="^(private|club|official)$")
|
||||
status: Optional[str] = None
|
||||
club_id: Optional[int] = Field(default=None, ge=1)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def at_least_visibility_or_status(self):
|
||||
if self.visibility is None and self.status is None:
|
||||
raise ValueError("Mindestens eines der Felder visibility oder status angeben")
|
||||
return self
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Helper Functions
|
||||
# ============================================================================
|
||||
|
|
@ -552,6 +571,127 @@ def _merge_str_any(multi: list[str], single: Optional[str]) -> list[str]:
|
|||
return out
|
||||
|
||||
|
||||
@router.patch("/exercises/bulk-metadata")
|
||||
def bulk_patch_exercises_metadata(
|
||||
body: ExerciseBulkMetadataPatch,
|
||||
tenant: TenantContext = Depends(get_tenant_context),
|
||||
):
|
||||
"""
|
||||
Ändert Sichtbarkeit und/oder Status für viele Übungen auf einmal.
|
||||
Berechtigt: Ersteller der jeweiligen Übung oder Plattform-Admin (admin/superadmin).
|
||||
Governance wie bei Einzel-PUT (official nur Plattform-Admin; club mit Mitgliedschaft bzw. Admin).
|
||||
"""
|
||||
profile_id = tenant.profile_id
|
||||
role = tenant.global_role
|
||||
|
||||
unique_ids = sorted({int(x) for x in body.exercise_ids if x is not None and int(x) > 0})
|
||||
if not unique_ids:
|
||||
raise HTTPException(status_code=400, detail="Keine gültigen Übungs-IDs")
|
||||
if len(unique_ids) > _MAX_BULK_METADATA_IDS:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Maximal {_MAX_BULK_METADATA_IDS} Übungen pro Anfrage",
|
||||
)
|
||||
|
||||
status_val: Optional[str] = None
|
||||
if body.status is not None:
|
||||
st = str(body.status).strip().lower()
|
||||
if st not in _VALID_EXERCISE_STATUS_BULK:
|
||||
raise HTTPException(status_code=400, detail="Ungültiger Status")
|
||||
status_val = st
|
||||
|
||||
patch_visibility = body.visibility is not None
|
||||
patch_status = status_val is not None
|
||||
|
||||
updated: List[int] = []
|
||||
failed: List[Dict[str, Any]] = []
|
||||
|
||||
def _fail_msg(he: HTTPException) -> str:
|
||||
d = he.detail
|
||||
return d if isinstance(d, str) else str(d)
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
for ex_id in unique_ids:
|
||||
cur.execute(
|
||||
"SELECT id, created_by, visibility, club_id FROM exercises WHERE id = %s",
|
||||
(ex_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
failed.append({"id": ex_id, "detail": "Übung nicht gefunden"})
|
||||
continue
|
||||
rowd = r2d(row)
|
||||
owner = rowd.get("created_by")
|
||||
if owner is not None:
|
||||
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_cid_raw = rowd.get("club_id")
|
||||
ex_cid = int(ex_cid_raw) if ex_cid_raw is not None else None
|
||||
|
||||
next_vis = ex_vis
|
||||
if patch_visibility:
|
||||
next_vis = str(body.visibility).strip().lower()
|
||||
|
||||
next_club = ex_cid
|
||||
if patch_visibility and body.club_id is not None:
|
||||
next_club = int(body.club_id)
|
||||
|
||||
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
|
||||
try:
|
||||
assert_valid_governance_visibility(cur, profile_id, role, next_vis, gov_club)
|
||||
except HTTPException as he:
|
||||
failed.append({"id": ex_id, "detail": _fail_msg(he)})
|
||||
continue
|
||||
|
||||
sets: List[str] = []
|
||||
vals: List[Any] = []
|
||||
if patch_visibility:
|
||||
sets.extend(["visibility = %s", "club_id = %s"])
|
||||
cid_out = next_club if next_vis == "club" else None
|
||||
vals.extend([next_vis, cid_out])
|
||||
if patch_status:
|
||||
sets.append("status = %s")
|
||||
vals.append(status_val)
|
||||
|
||||
sets.append("updated_at = NOW()")
|
||||
vals.append(ex_id)
|
||||
cur.execute(
|
||||
f"UPDATE exercises SET {', '.join(sets)} WHERE id = %s",
|
||||
tuple(vals),
|
||||
)
|
||||
updated.append(ex_id)
|
||||
conn.commit()
|
||||
|
||||
return {
|
||||
"updated": updated,
|
||||
"failed": failed,
|
||||
"updated_count": len(updated),
|
||||
"failed_count": len(failed),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/exercises")
|
||||
def list_exercises(
|
||||
focus_area_ids: list[int] = Query(default=[], description="ODER: mind. einer dieser Fokusbereiche"),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Shinkan Jinkendo Version Information
|
||||
|
||||
APP_VERSION = "0.8.28"
|
||||
APP_VERSION = "0.8.29"
|
||||
BUILD_DATE = "2026-05-05"
|
||||
DB_SCHEMA_VERSION = "20260505041"
|
||||
|
||||
|
|
@ -15,7 +15,7 @@ MODULE_VERSIONS = {
|
|||
"groups": "0.1.0",
|
||||
"skills": "0.1.0",
|
||||
"methods": "0.1.0",
|
||||
"exercises": "2.6.4", # Detail-Lesen über library_content_visible_to_profile
|
||||
"exercises": "2.7.0", # PATCH /exercises/bulk-metadata — Massenänderung Sichtbarkeit/Status
|
||||
"training_units": "0.1.0",
|
||||
"training_programs": "0.1.0",
|
||||
"planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile
|
||||
|
|
@ -27,6 +27,14 @@ MODULE_VERSIONS = {
|
|||
}
|
||||
|
||||
CHANGELOG = [
|
||||
{
|
||||
"version": "0.8.29",
|
||||
"date": "2026-05-05",
|
||||
"changes": [
|
||||
"Übungen: PATCH /api/exercises/bulk-metadata (bis 500 IDs) — Sichtbarkeit und/oder Status; Ersteller oder Plattform-Admin; Governance wie Einzel-PUT",
|
||||
"Übungsliste: Mehrfachauswahl, Alle auf dieser Seite, Dialog Massenänderung (Verein/offiziell nur Admins)",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.28",
|
||||
"date": "2026-05-05",
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
import React, { useState, useEffect, useMemo, useCallback } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import api from '../utils/api'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels'
|
||||
import MultiSelectCombo from '../components/MultiSelectCombo'
|
||||
import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel'
|
||||
|
||||
const PAGE_SIZE = 100
|
||||
const BULK_MAX_IDS = 500
|
||||
const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null)
|
||||
|
||||
const INITIAL_FILTERS = {
|
||||
|
|
@ -26,6 +28,9 @@ function levelOptionShort(levelStr) {
|
|||
}
|
||||
|
||||
function ExercisesListPage() {
|
||||
const { user } = useAuth()
|
||||
const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
|
||||
|
||||
const [exercises, setExercises] = useState([])
|
||||
const [catalogs, setCatalogs] = useState({
|
||||
focusAreas: [],
|
||||
|
|
@ -47,6 +52,14 @@ function ExercisesListPage() {
|
|||
const [filterModalOpen, setFilterModalOpen] = useState(false)
|
||||
const [pageTab, setPageTab] = useState('list')
|
||||
|
||||
const [selectedIds, setSelectedIds] = useState(() => new Set())
|
||||
const [bulkModalOpen, setBulkModalOpen] = useState(false)
|
||||
const [bulkVisibility, setBulkVisibility] = useState('')
|
||||
const [bulkStatus, setBulkStatus] = useState('')
|
||||
const [bulkClubSelect, setBulkClubSelect] = useState('')
|
||||
const [bulkClubManual, setBulkClubManual] = useState('')
|
||||
const [bulkSubmitting, setBulkSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedSearch(searchInput.trim()), 400)
|
||||
return () => clearTimeout(t)
|
||||
|
|
@ -254,6 +267,67 @@ function ExercisesListPage() {
|
|||
return q
|
||||
}, [filters, debouncedSearch, debouncedAiSearch])
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIds(new Set())
|
||||
}, [queryBase])
|
||||
|
||||
const clubNameById = useMemo(() => {
|
||||
const m = {}
|
||||
for (const c of user?.clubs || []) {
|
||||
if (c?.id != null) m[Number(c.id)] = c.name || `#${c.id}`
|
||||
}
|
||||
return m
|
||||
}, [user?.clubs])
|
||||
|
||||
const effectiveClubId =
|
||||
user?.effective_club_id != null && user.effective_club_id !== ''
|
||||
? Number(user.effective_club_id)
|
||||
: user?.active_club_id != null && user.active_club_id !== ''
|
||||
? Number(user.active_club_id)
|
||||
: null
|
||||
|
||||
const toggleSelect = useCallback((id) => {
|
||||
setSelectedIds((prev) => {
|
||||
const n = new Set(prev)
|
||||
const nid = Number(id)
|
||||
if (Number.isNaN(nid)) return prev
|
||||
if (n.has(nid)) n.delete(nid)
|
||||
else n.add(nid)
|
||||
return n
|
||||
})
|
||||
}, [])
|
||||
|
||||
const clearSelection = useCallback(() => setSelectedIds(new Set()), [])
|
||||
|
||||
const toggleSelectAllPage = useCallback(() => {
|
||||
setSelectedIds((prev) => {
|
||||
const n = new Set(prev)
|
||||
const allSel =
|
||||
exercises.length > 0 && exercises.every((e) => n.has(Number(e.id)))
|
||||
if (allSel) {
|
||||
exercises.forEach((e) => n.delete(Number(e.id)))
|
||||
} else {
|
||||
exercises.forEach((e) => n.add(Number(e.id)))
|
||||
}
|
||||
return n
|
||||
})
|
||||
}, [exercises])
|
||||
|
||||
const allOnPageSelected = useMemo(
|
||||
() => exercises.length > 0 && exercises.every((e) => selectedIds.has(Number(e.id))),
|
||||
[exercises, selectedIds]
|
||||
)
|
||||
|
||||
const bulkVisibilityOptions = useMemo(() => {
|
||||
const base = [
|
||||
{ id: '', label: '— nicht ändern —' },
|
||||
{ id: 'private', label: 'Privat' },
|
||||
{ id: 'club', label: 'Verein' },
|
||||
]
|
||||
if (isPlatformAdmin) base.push({ id: 'official', label: 'Offiziell (global)' })
|
||||
return base
|
||||
}, [isPlatformAdmin])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
|
|
@ -342,6 +416,85 @@ function ExercisesListPage() {
|
|||
|
||||
const resetAllFilters = useCallback(() => setFilters({ ...INITIAL_FILTERS }), [])
|
||||
|
||||
const openBulkModal = () => {
|
||||
setBulkVisibility('')
|
||||
setBulkStatus('')
|
||||
setBulkClubSelect('')
|
||||
setBulkClubManual('')
|
||||
setBulkModalOpen(true)
|
||||
}
|
||||
|
||||
const handleBulkSubmit = async () => {
|
||||
if (!bulkVisibility && !bulkStatus) {
|
||||
alert('Bitte mindestens Sichtbarkeit oder Status wählen (nicht „nicht ändern“ bei beiden).')
|
||||
return
|
||||
}
|
||||
const ids = Array.from(selectedIds).filter((x) => Number.isFinite(x) && x > 0)
|
||||
if (ids.length === 0) {
|
||||
alert('Keine Übungen ausgewählt.')
|
||||
return
|
||||
}
|
||||
if (ids.length > BULK_MAX_IDS) {
|
||||
alert(`Maximal ${BULK_MAX_IDS} Übungen pro Vorgang. Bitte Auswahl oder mehrere Durchläufe verwenden.`)
|
||||
return
|
||||
}
|
||||
const payload = { exercise_ids: ids }
|
||||
if (bulkVisibility) payload.visibility = bulkVisibility
|
||||
if (bulkStatus) payload.status = bulkStatus
|
||||
if (bulkVisibility === 'club') {
|
||||
const manual = String(bulkClubManual || '').trim()
|
||||
if (manual && /^\d+$/.test(manual)) payload.club_id = Number(manual)
|
||||
else if (bulkClubSelect && /^\d+$/.test(String(bulkClubSelect))) {
|
||||
payload.club_id = Number(bulkClubSelect)
|
||||
}
|
||||
}
|
||||
setBulkSubmitting(true)
|
||||
try {
|
||||
const res = await api.bulkPatchExercisesMetadata(payload)
|
||||
const updatedSet = new Set((res.updated || []).map((x) => Number(x)))
|
||||
let resolvedClubId = null
|
||||
if (bulkVisibility === 'club') {
|
||||
if (payload.club_id != null) resolvedClubId = payload.club_id
|
||||
else if (effectiveClubId != null && !Number.isNaN(effectiveClubId)) resolvedClubId = effectiveClubId
|
||||
}
|
||||
const clubLabel =
|
||||
resolvedClubId != null ? clubNameById[resolvedClubId] || `Verein #${resolvedClubId}` : null
|
||||
|
||||
setExercises((prev) =>
|
||||
prev.map((e) => {
|
||||
if (!updatedSet.has(Number(e.id))) return e
|
||||
const next = { ...e }
|
||||
if (bulkVisibility) {
|
||||
next.visibility = bulkVisibility
|
||||
next.club_id = bulkVisibility === 'club' ? resolvedClubId : null
|
||||
next.club_name = bulkVisibility === 'club' ? clubLabel : null
|
||||
}
|
||||
if (bulkStatus) next.status = bulkStatus
|
||||
return next
|
||||
})
|
||||
)
|
||||
|
||||
let msg = `${res.updated_count ?? updatedSet.size} Übung(en) aktualisiert.`
|
||||
if (res.failed_count) msg += `\n${res.failed_count} nicht geändert (siehe Details).`
|
||||
if (Array.isArray(res.failed) && res.failed.length) {
|
||||
msg +=
|
||||
'\n\n' +
|
||||
res.failed
|
||||
.slice(0, 12)
|
||||
.map((f) => `#${f.id}: ${f.detail}`)
|
||||
.join('\n')
|
||||
if (res.failed.length > 12) msg += '\n…'
|
||||
}
|
||||
alert(msg)
|
||||
setBulkModalOpen(false)
|
||||
clearSelection()
|
||||
} catch (err) {
|
||||
alert('Massenänderung fehlgeschlagen: ' + (err.message || String(err)))
|
||||
} finally {
|
||||
setBulkSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!catalogsReady && pageTab === 'list') {
|
||||
return (
|
||||
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
||||
|
|
@ -470,9 +623,43 @@ function ExercisesListPage() {
|
|||
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: '10px', marginBottom: 0 }}>
|
||||
Trefferliste aktualisiert sich kurz nach Eingabe. Titel der aktuellen Liste erscheinen als Vorschläge (Pfeil im
|
||||
Feld). Fachliche Filter über „Filter“ – zwischen Feldern UND, Auswahl mehrerer Werte je Feld mit ODER.
|
||||
{exercises.length > 0 ? (
|
||||
<>
|
||||
{' '}
|
||||
<button type="button" className="btn btn-secondary" style={{ marginLeft: '6px' }} onClick={toggleSelectAllPage}>
|
||||
{allOnPageSelected ? 'Auswahl auf dieser Seite aufheben' : 'Alle auf dieser Seite auswählen'}
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{selectedIds.size > 0 ? (
|
||||
<div
|
||||
className="card"
|
||||
style={{
|
||||
marginBottom: '12px',
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
}}
|
||||
>
|
||||
<strong>{selectedIds.size} ausgewählt</strong>
|
||||
<button type="button" className="btn btn-secondary" onClick={clearSelection}>
|
||||
Auswahl aufheben
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" onClick={openBulkModal}>
|
||||
Sichtbarkeit / Status ändern…
|
||||
</button>
|
||||
<span style={{ fontSize: '12px', color: 'var(--text3)' }}>
|
||||
Bis zu {BULK_MAX_IDS} pro Anfrage. Für „Verein“ ohne Auswahl: aktiver Vereinskontext (
|
||||
<code>X-Active-Club-Id</code>
|
||||
).
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{filterModalOpen && (
|
||||
<div
|
||||
className="admin-modal-backdrop"
|
||||
|
|
@ -637,6 +824,118 @@ function ExercisesListPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{bulkModalOpen ? (
|
||||
<div
|
||||
className="admin-modal-backdrop"
|
||||
role="presentation"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) setBulkModalOpen(false)
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="admin-modal-sheet exercise-filter-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="exercise-bulk-modal-title"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="admin-modal-sheet__header">
|
||||
<h3 id="exercise-bulk-modal-title" className="admin-modal-sheet__title">
|
||||
Massenänderung: Sichtbarkeit / Status
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary admin-modal-sheet__close"
|
||||
onClick={() => setBulkModalOpen(false)}
|
||||
>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
<div className="admin-modal-sheet__body exercise-filter-modal__scroll">
|
||||
<p style={{ fontSize: '13px', color: 'var(--text2)', marginTop: 0 }}>
|
||||
Es werden <strong>{selectedIds.size}</strong> Übung(en) aus der aktuellen Auswahl bearbeitet. Pro Durchlauf
|
||||
höchstens {BULK_MAX_IDS}. Ohne Berechtigung bleiben Einzelübungen unverändert (siehe Hinweis nach dem
|
||||
Speichern).
|
||||
</p>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Sichtbarkeit</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={bulkVisibility}
|
||||
onChange={(e) => setBulkVisibility(e.target.value)}
|
||||
>
|
||||
{bulkVisibilityOptions.map((o) => (
|
||||
<option key={o.id === '' ? '_unchanged' : o.id} value={o.id}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{bulkVisibility === 'club' ? (
|
||||
<div className="form-row">
|
||||
<label className="form-label">Verein zuordnen</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={bulkClubSelect}
|
||||
onChange={(e) => setBulkClubSelect(e.target.value)}
|
||||
>
|
||||
<option value="">Aktiver Verein (Vereins-Umschalter / Header)</option>
|
||||
{(user?.clubs || []).map((c) => (
|
||||
<option key={c.id} value={String(c.id)}>
|
||||
{c.name || `#${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={bulkClubManual}
|
||||
onChange={(e) => setBulkClubManual(e.target.value)}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="form-row">
|
||||
<label className="form-label">Status</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={bulkStatus}
|
||||
onChange={(e) => setBulkStatus(e.target.value)}
|
||||
>
|
||||
<option value="">— nicht ändern —</option>
|
||||
{statusOptions.map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="exercise-filter-modal__footer" style={{ justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={bulkSubmitting}
|
||||
onClick={() => setBulkModalOpen(false)}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" disabled={bulkSubmitting} onClick={handleBulkSubmit}>
|
||||
{bulkSubmitting ? 'Speichern…' : 'Anwenden'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{listFetching && exercises.length === 0 ? (
|
||||
<div className="card" style={{ textAlign: 'center', padding: '2rem' }}>
|
||||
<div className="spinner"></div>
|
||||
|
|
@ -666,7 +965,15 @@ function ExercisesListPage() {
|
|||
>
|
||||
{exercises.map((exercise) => (
|
||||
<div key={exercise.id} className="card exercise-card">
|
||||
<div className="exercise-card__body">
|
||||
<div style={{ display: 'flex', gap: '10px', alignItems: 'flex-start' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.has(Number(exercise.id))}
|
||||
onChange={() => toggleSelect(exercise.id)}
|
||||
aria-label={`„${(exercise.title || 'Übung').replace(/"/g, '')}“ auswählen`}
|
||||
style={{ marginTop: '4px', flexShrink: 0 }}
|
||||
/>
|
||||
<div className="exercise-card__body" style={{ flex: 1, minWidth: 0 }}>
|
||||
<h3 style={{ marginBottom: '8px', fontSize: '1.05rem', lineHeight: 1.3 }}>
|
||||
<Link
|
||||
to={`/exercises/${exercise.id}`}
|
||||
|
|
@ -689,6 +996,7 @@ function ExercisesListPage() {
|
|||
: exercise.summary}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="exercise-card__actions">
|
||||
<Link to={`/exercises/${exercise.id}`} className="btn btn-secondary">
|
||||
|
|
|
|||
|
|
@ -508,6 +508,14 @@ export async function updateExercise(id, data) {
|
|||
})
|
||||
}
|
||||
|
||||
/** Massenänderung Sichtbarkeit / Status (`PATCH /api/exercises/bulk-metadata`). */
|
||||
export async function bulkPatchExercisesMetadata(data) {
|
||||
return request('/api/exercises/bulk-metadata', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteExercise(id) {
|
||||
return request(`/api/exercises/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
|
@ -1156,6 +1164,7 @@ export const api = {
|
|||
getExercise,
|
||||
createExercise,
|
||||
updateExercise,
|
||||
bulkPatchExercisesMetadata,
|
||||
deleteExercise,
|
||||
createExerciseVariant,
|
||||
updateExerciseVariant,
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
// Shinkan Jinkendo Frontend Version
|
||||
|
||||
export const APP_VERSION = "0.8.28"
|
||||
export const APP_VERSION = "0.8.29"
|
||||
export const BUILD_DATE = "2026-05-05"
|
||||
|
||||
export const PAGE_VERSIONS = {
|
||||
LoginPage: "1.0.0",
|
||||
Dashboard: "1.0.0",
|
||||
AccountSettingsPage: "1.0.0",
|
||||
ExercisesPage: "1.1.0", // Updated: Katalog-Integration
|
||||
ExercisesPage: "1.2.0", // Massenänderung Sichtbarkeit/Status auf der Liste
|
||||
ClubsPage: "1.1.0",
|
||||
SkillsPage: "1.0.0",
|
||||
TrainingPlanningPage: "1.3.1",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user