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 |
|
| 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_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 | |
|
| 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 |
|
| 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_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 |
|
| 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 logging
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
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 fastapi import APIRouter, HTTPException, Depends, Query, UploadFile, File, Form
|
||||||
from pydantic import BaseModel, Field, model_validator
|
from pydantic import BaseModel, Field, model_validator
|
||||||
|
|
@ -213,6 +213,25 @@ class ExerciseVariantsReorder(BaseModel):
|
||||||
variant_ids: list[int]
|
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
|
# Helper Functions
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
@ -552,6 +571,127 @@ def _merge_str_any(multi: list[str], single: Optional[str]) -> list[str]:
|
||||||
return out
|
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")
|
@router.get("/exercises")
|
||||||
def list_exercises(
|
def list_exercises(
|
||||||
focus_area_ids: list[int] = Query(default=[], description="ODER: mind. einer dieser Fokusbereiche"),
|
focus_area_ids: list[int] = Query(default=[], description="ODER: mind. einer dieser Fokusbereiche"),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.28"
|
APP_VERSION = "0.8.29"
|
||||||
BUILD_DATE = "2026-05-05"
|
BUILD_DATE = "2026-05-05"
|
||||||
DB_SCHEMA_VERSION = "20260505041"
|
DB_SCHEMA_VERSION = "20260505041"
|
||||||
|
|
||||||
|
|
@ -15,7 +15,7 @@ MODULE_VERSIONS = {
|
||||||
"groups": "0.1.0",
|
"groups": "0.1.0",
|
||||||
"skills": "0.1.0",
|
"skills": "0.1.0",
|
||||||
"methods": "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_units": "0.1.0",
|
||||||
"training_programs": "0.1.0",
|
"training_programs": "0.1.0",
|
||||||
"planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile
|
"planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile
|
||||||
|
|
@ -27,6 +27,14 @@ MODULE_VERSIONS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
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",
|
"version": "0.8.28",
|
||||||
"date": "2026-05-05",
|
"date": "2026-05-05",
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
import React, { useState, useEffect, useMemo, useCallback } from 'react'
|
import React, { useState, useEffect, useMemo, useCallback } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels'
|
import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels'
|
||||||
import MultiSelectCombo from '../components/MultiSelectCombo'
|
import MultiSelectCombo from '../components/MultiSelectCombo'
|
||||||
import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel'
|
import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel'
|
||||||
|
|
||||||
const PAGE_SIZE = 100
|
const PAGE_SIZE = 100
|
||||||
|
const BULK_MAX_IDS = 500
|
||||||
const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null)
|
const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null)
|
||||||
|
|
||||||
const INITIAL_FILTERS = {
|
const INITIAL_FILTERS = {
|
||||||
|
|
@ -26,6 +28,9 @@ function levelOptionShort(levelStr) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function ExercisesListPage() {
|
function ExercisesListPage() {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
|
||||||
|
|
||||||
const [exercises, setExercises] = useState([])
|
const [exercises, setExercises] = useState([])
|
||||||
const [catalogs, setCatalogs] = useState({
|
const [catalogs, setCatalogs] = useState({
|
||||||
focusAreas: [],
|
focusAreas: [],
|
||||||
|
|
@ -47,6 +52,14 @@ function ExercisesListPage() {
|
||||||
const [filterModalOpen, setFilterModalOpen] = useState(false)
|
const [filterModalOpen, setFilterModalOpen] = useState(false)
|
||||||
const [pageTab, setPageTab] = useState('list')
|
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(() => {
|
useEffect(() => {
|
||||||
const t = setTimeout(() => setDebouncedSearch(searchInput.trim()), 400)
|
const t = setTimeout(() => setDebouncedSearch(searchInput.trim()), 400)
|
||||||
return () => clearTimeout(t)
|
return () => clearTimeout(t)
|
||||||
|
|
@ -254,6 +267,67 @@ function ExercisesListPage() {
|
||||||
return q
|
return q
|
||||||
}, [filters, debouncedSearch, debouncedAiSearch])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
;(async () => {
|
;(async () => {
|
||||||
|
|
@ -342,6 +416,85 @@ function ExercisesListPage() {
|
||||||
|
|
||||||
const resetAllFilters = useCallback(() => setFilters({ ...INITIAL_FILTERS }), [])
|
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') {
|
if (!catalogsReady && pageTab === 'list') {
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
||||||
|
|
@ -470,9 +623,43 @@ function ExercisesListPage() {
|
||||||
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: '10px', marginBottom: 0 }}>
|
<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
|
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.
|
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>
|
</p>
|
||||||
</div>
|
</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 && (
|
{filterModalOpen && (
|
||||||
<div
|
<div
|
||||||
className="admin-modal-backdrop"
|
className="admin-modal-backdrop"
|
||||||
|
|
@ -637,6 +824,118 @@ function ExercisesListPage() {
|
||||||
</div>
|
</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 ? (
|
{listFetching && exercises.length === 0 ? (
|
||||||
<div className="card" style={{ textAlign: 'center', padding: '2rem' }}>
|
<div className="card" style={{ textAlign: 'center', padding: '2rem' }}>
|
||||||
<div className="spinner"></div>
|
<div className="spinner"></div>
|
||||||
|
|
@ -666,7 +965,15 @@ function ExercisesListPage() {
|
||||||
>
|
>
|
||||||
{exercises.map((exercise) => (
|
{exercises.map((exercise) => (
|
||||||
<div key={exercise.id} className="card exercise-card">
|
<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 }}>
|
<h3 style={{ marginBottom: '8px', fontSize: '1.05rem', lineHeight: 1.3 }}>
|
||||||
<Link
|
<Link
|
||||||
to={`/exercises/${exercise.id}`}
|
to={`/exercises/${exercise.id}`}
|
||||||
|
|
@ -689,6 +996,7 @@ function ExercisesListPage() {
|
||||||
: exercise.summary}
|
: exercise.summary}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="exercise-card__actions">
|
<div className="exercise-card__actions">
|
||||||
<Link to={`/exercises/${exercise.id}`} className="btn btn-secondary">
|
<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) {
|
export async function deleteExercise(id) {
|
||||||
return request(`/api/exercises/${id}`, { method: 'DELETE' })
|
return request(`/api/exercises/${id}`, { method: 'DELETE' })
|
||||||
}
|
}
|
||||||
|
|
@ -1156,6 +1164,7 @@ export const api = {
|
||||||
getExercise,
|
getExercise,
|
||||||
createExercise,
|
createExercise,
|
||||||
updateExercise,
|
updateExercise,
|
||||||
|
bulkPatchExercisesMetadata,
|
||||||
deleteExercise,
|
deleteExercise,
|
||||||
createExerciseVariant,
|
createExerciseVariant,
|
||||||
updateExerciseVariant,
|
updateExerciseVariant,
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
// Shinkan Jinkendo Frontend Version
|
// 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 BUILD_DATE = "2026-05-05"
|
||||||
|
|
||||||
export const PAGE_VERSIONS = {
|
export const PAGE_VERSIONS = {
|
||||||
LoginPage: "1.0.0",
|
LoginPage: "1.0.0",
|
||||||
Dashboard: "1.0.0",
|
Dashboard: "1.0.0",
|
||||||
AccountSettingsPage: "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",
|
ClubsPage: "1.1.0",
|
||||||
SkillsPage: "1.0.0",
|
SkillsPage: "1.0.0",
|
||||||
TrainingPlanningPage: "1.3.1",
|
TrainingPlanningPage: "1.3.1",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user