diff --git a/backend/routers/training_planning.py b/backend/routers/training_planning.py index c64e697..8a25bde 100644 --- a/backend/routers/training_planning.py +++ b/backend/routers/training_planning.py @@ -4,6 +4,7 @@ und wiederverwendbare Trainingsvorlagen (Sektions-Gliederung). Governance: Sichtbarkeit wie Übungen (private / club / official); Schreiben nur Ersteller oder Plattform-Admin. """ +from datetime import date, timedelta from typing import Any, Dict, List, Optional from fastapi import APIRouter, Depends, HTTPException, Query @@ -692,6 +693,26 @@ def _group_club_id_for_scheduled_unit(cur, unit_id: int) -> Optional[int]: return int(r["club_id"]) +def _exercise_needs_club_visibility_for_target(ex: Dict[str, Any], target_club_id: int) -> bool: + """Übung für Mitglieder des Ziel-Vereins in der Durchführung sichtbar machen (Dashboard/Queue).""" + if str(ex.get("status") or "").strip().lower() == "archived": + return False + vis = (ex.get("visibility") or "private").strip().lower() + if vis == "official": + return False + if vis == "private": + return True + if vis == "club": + raw = ex.get("club_id") + if raw is None: + return True + try: + return int(raw) != int(target_club_id) + except (TypeError, ValueError): + return True + return False + + def _caller_may_promote_exercise_to_club( cur, exercise_created_by: Optional[int], @@ -1209,6 +1230,163 @@ def list_training_units( return [r2d(r) for r in rows] +@router.get("/training-units/exercises-club-visibility-queue") +def exercises_club_visibility_queue( + start_date: Optional[str] = Query(default=None), + end_date: Optional[str] = Query(default=None), + assigned_to_me: bool = Query(default=True), + limit_units: int = Query(default=80, ge=1, le=150), + tenant: TenantContext = Depends(get_tenant_context), +): + """ + Übungen in deinen Trainingseinheiten (Zeitfenster), die für den jeweiligen Verein der Gruppe + noch nicht vereinsweit sichtbar sind — für Dashboard & Freigabe-Workflow. + """ + profile_id = tenant.profile_id + role = tenant.global_role + + if start_date is None: + start_date = (date.today() - timedelta(days=45)).isoformat() + if end_date is None: + end_date = (date.today() + timedelta(days=365)).isoformat() + + units = list_training_units( + group_id=None, + club_id=None, + start_date=start_date, + end_date=end_date, + status=None, + assigned_to_me=assigned_to_me, + debrief_pending=False, + sort="asc", + limit=limit_units, + tenant=tenant, + ) + unit_ids = [int(u["id"]) for u in units if u.get("id") is not None] + if not unit_ids: + return {"items": []} + + placeholders = ",".join(["%s"] * len(unit_ids)) + items: List[Dict[str, Any]] = [] + + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + f""" + SELECT DISTINCT tu.id AS unit_id, + tu.planned_date, + tg.name AS group_name, + tg.club_id AS target_club_id, + c.name AS target_club_name, + tusi.exercise_id AS exercise_id + FROM training_units tu + INNER JOIN training_groups tg ON tu.group_id = tg.id + LEFT JOIN clubs c ON c.id = tg.club_id + INNER JOIN training_unit_sections tus ON tus.training_unit_id = tu.id + INNER JOIN training_unit_section_items tusi ON tusi.section_id = tus.id + WHERE tu.id IN ({placeholders}) + AND tu.framework_slot_id IS NULL + AND tusi.item_type = 'exercise' + AND tusi.exercise_id IS NOT NULL + """, + tuple(unit_ids), + ) + pairs = [r2d(r) for r in cur.fetchall()] + if not pairs: + return {"items": []} + + ex_ids = sorted( + {int(p["exercise_id"]) for p in pairs if p.get("exercise_id") is not None} + ) + if not ex_ids: + return {"items": []} + + exercises_map: Dict[int, Dict[str, Any]] = {} + ph = ",".join(["%s"] * len(ex_ids)) + cur.execute( + f""" + SELECT id, title, visibility, club_id, created_by, status + FROM exercises + WHERE id IN ({ph}) + """, + tuple(ex_ids), + ) + for r in cur.fetchall(): + d = r2d(r) + exercises_map[int(d["id"])] = d + + agg: Dict[tuple, Dict[str, Any]] = {} + for p in pairs: + try: + ex_id = int(p["exercise_id"]) + except (TypeError, ValueError): + continue + tc_raw = p.get("target_club_id") + if tc_raw is None: + continue + tc = int(tc_raw) + key = (ex_id, tc) + if key not in agg: + agg[key] = { + "exercise_id": ex_id, + "target_club_id": tc, + "target_club_name": (p.get("target_club_name") or "").strip(), + "units": [], + } + uid = p.get("unit_id") + if uid is None: + continue + agg[key]["units"].append( + { + "id": int(uid), + "planned_date": str(p["planned_date"]) if p.get("planned_date") is not None else "", + "group_name": (p.get("group_name") or "").strip(), + } + ) + + for _key, blob in agg.items(): + ex_id = blob["exercise_id"] + tc = blob["target_club_id"] + ex = exercises_map.get(ex_id) + if not ex: + continue + if not _exercise_needs_club_visibility_for_target(ex, tc): + continue + uniq_units = {u["id"]: u for u in blob["units"]}.values() + ulist = sorted( + uniq_units, + key=lambda x: (x.get("planned_date") or "", x.get("id")), + ) + cb = ex.get("created_by") + cb_int = int(cb) if cb is not None else None + can_promote = _caller_may_promote_exercise_to_club(cur, cb_int, profile_id, role, tc) + vis = (ex.get("visibility") or "private").strip().lower() + st = (ex.get("status") or "draft").strip().lower() + ecid = ex.get("club_id") + items.append( + { + "exercise_id": ex_id, + "title": (ex.get("title") or f"Übung #{ex_id}").strip() or f"Übung #{ex_id}", + "visibility": vis, + "status": st, + "club_id": int(ecid) if ecid is not None else None, + "created_by": cb_int, + "target_club_id": tc, + "target_club_name": blob.get("target_club_name") or "", + "can_promote": can_promote, + "units": ulist, + } + ) + + items.sort( + key=lambda x: ( + (x["units"][0].get("planned_date") if x["units"] else ""), + x["title"], + ) + ) + return {"items": items} + + @router.get("/training-units/{unit_id}") def get_training_unit(unit_id: int, tenant: TenantContext = Depends(get_tenant_context)): profile_id = tenant.profile_id diff --git a/frontend/src/components/DashboardTrainingVisibilityWidget.jsx b/frontend/src/components/DashboardTrainingVisibilityWidget.jsx new file mode 100644 index 0000000..230e538 --- /dev/null +++ b/frontend/src/components/DashboardTrainingVisibilityWidget.jsx @@ -0,0 +1,347 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import { Link } from 'react-router-dom' +import { CalendarDays, ClipboardList } from 'lucide-react' +import api from '../utils/api' + +const VIS_LABELS = { private: 'Privat', club: 'Verein', official: 'Offiziell' } +const STATUS_LABELS = { + draft: 'Entwurf', + in_review: 'Prüfung', + approved: 'Freigegeben', + archived: 'Archiv', +} + +function rowKey(item) { + return `${item.exercise_id}-${item.target_club_id}` +} + +function unitPlanTitle(u) { + const d = (u.planned_date || '').toString().slice(0, 10) + const g = (u.group_name || '').trim() + return [d, g].filter(Boolean).join(' · ') || `Einheit #${u.id}` +} + +/** + * Dashboard: Übungen aus eigenen Trainingseinheiten, die für den Verein der Gruppe noch nicht freigegeben sind. + */ +export default function DashboardTrainingVisibilityWidget({ user }) { + const [items, setItems] = useState([]) + const [loading, setLoading] = useState(true) + const [err, setErr] = useState(null) + const [selected, setSelected] = useState(() => new Set()) + const [busy, setBusy] = useState(false) + const [msg, setMsg] = useState(null) + + const load = useCallback(async () => { + if (!user?.id) return + setErr(null) + setLoading(true) + try { + const res = await api.getTrainingExerciseClubVisibilityQueue({ limit_units: 100 }) + const list = Array.isArray(res?.items) ? res.items : [] + setItems(list) + setSelected(new Set()) + setMsg(null) + } catch (e) { + setErr(e?.message || String(e)) + setItems([]) + } finally { + setLoading(false) + } + }, [user?.id]) + + useEffect(() => { + load() + }, [load]) + + const promotableSelected = useMemo(() => { + const out = [] + for (const k of selected) { + const it = items.find((x) => rowKey(x) === k) + if (it?.can_promote) out.push(it) + } + return out + }, [items, selected]) + + const toggle = (key) => { + setSelected((prev) => { + const n = new Set(prev) + if (n.has(key)) n.delete(key) + else n.add(key) + return n + }) + } + + const toggleAllPromotable = () => { + const keys = items.filter((i) => i.can_promote).map(rowKey) + setSelected((prev) => { + if (keys.length && keys.every((k) => prev.has(k))) { + return new Set() + } + return new Set(keys) + }) + } + + const promoteSelected = async () => { + if (!promotableSelected.length) return + setBusy(true) + setMsg(null) + try { + const byClub = new Map() + for (const it of promotableSelected) { + const cid = it.target_club_id + if (!byClub.has(cid)) byClub.set(cid, []) + byClub.get(cid).push(it.exercise_id) + } + let anyFail = false + for (const [clubId, ids] of byClub) { + const uniq = [...new Set(ids)] + const res = await api.bulkPatchExercisesMetadata({ + exercise_ids: uniq, + visibility: 'club', + club_id: clubId, + }) + if ((res?.failed || []).length) { + anyFail = true + const f = res.failed[0] + setMsg(f?.detail || 'Freigabe teilweise fehlgeschlagen') + } + } + if (!anyFail) setMsg(null) + await load() + } catch (e) { + setMsg(e?.message || String(e)) + } finally { + setBusy(false) + } + } + + if (!user?.id) return null + + if (loading) { + return ( +
+ Vereinsfreigaben werden geladen… +
+{err}
++ Keine Übungen in den abgefragten Einheiten, die noch auf Verein gestellt werden müssten. +
++ Übungen in deinen Einheiten, die für den jeweiligen Verein noch nicht sichtbar sind — auf{' '} + Verein setzen oder zur Bearbeitung / Planung springen. +
+| + | Übung | +Sichtbarkeit | +Status | +Verein | ++ Kontext + | +
|---|---|---|---|---|---|
| + toggle(k)} + title={it.can_promote ? 'Zur Freigabe wählen' : 'Keine Berechtigung für diese Freigabe'} + aria-label={`${it.title} auswählen`} + /> + | ++ + {it.title} + + | +{visL} | +{stL} | ++ {cn.length > 28 ? `${cn.slice(0, 28)}…` : cn || '—'} + | +
+ {first ? (
+
+ 0
+ ? `Planung (${unitPlanTitle(first)}; +${restN} weitere — siehe Tooltip ganze Zeile)`
+ : `Planung öffnen: ${unitPlanTitle(first)}`
+ }
+ aria-label="Planung öffnen"
+ >
+
+ ) : (
+ '—'
+ )}
+ |
+
+ Ausgegraute Kästchen: keine direkte Freigabe-Berechtigung — Vereinsorga kontaktieren oder die Einheit in + der Planung speichern (dann ggf. automatische Vereinsfreigabe). +
+ ) : null} + ++ Mehrere Termine: Kalender-Icon nutzt den frühesten; „+N“ listet alle Daten und Gruppen im Tooltip. +
+ + {msg ? ( ++ {msg} +
+ ) : null} + ++ Zur Trainingsplanung + · Zeitraum ca. 45 Tage zurück bis 1 Jahr voraus; bis zu 100 Einheiten. +
+