diff --git a/backend/migrations/044_training_unit_debrief_completed.sql b/backend/migrations/044_training_unit_debrief_completed.sql new file mode 100644 index 0000000..b34459f --- /dev/null +++ b/backend/migrations/044_training_unit_debrief_completed.sql @@ -0,0 +1,10 @@ +-- Rückschau / Nachbereitung: explizit abschließbar (Dashboard & Filter) +ALTER TABLE training_units + ADD COLUMN IF NOT EXISTS debrief_completed_at TIMESTAMPTZ NULL; + +COMMENT ON COLUMN training_units.debrief_completed_at IS + 'Zeitpunkt, zu dem die Trainer-Rückschau (Nachbereitung) bewusst abgeschlossen wurde; NULL = offen'; + +CREATE INDEX IF NOT EXISTS idx_training_units_debrief_open + ON training_units (status, debrief_completed_at) + WHERE status = 'completed' AND debrief_completed_at IS NULL; diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py index 8ef0bef..cc28842 100644 --- a/backend/routers/exercises.py +++ b/backend/routers/exercises.py @@ -17,6 +17,7 @@ from pydantic import BaseModel, Field, model_validator from db import get_db, get_cursor, r2d from club_tenancy import ( assert_valid_governance_visibility, + can_manage_club_org, club_admin_shares_club_with_creator, has_club_role, is_platform_admin, @@ -778,6 +779,10 @@ def bulk_patch_exercises_metadata( Übungen vollständig durch diese Liste ersetzt (leeres Array entfernt alle). 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). """ profile_id = tenant.profile_id @@ -861,14 +866,6 @@ def bulk_patch_exercises_metadata( 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") @@ -882,18 +879,45 @@ def bulk_patch_exercises_metadata( if patch_visibility and body.club_id is not None: 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 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) @@ -1009,6 +1033,10 @@ def list_exercises( default=False, description="Archivierte einbeziehen; Standard false (außer Statusfilter enthält archived)", ), + created_by_me: bool = Query( + default=False, + description="Nur Übungen, die vom aktuellen Profil angelegt wurden (created_by = Profil)", + ), tenant: TenantContext = Depends(get_tenant_context), ): """ @@ -1036,6 +1064,10 @@ def list_exercises( where.append(vis_sql) params.extend(vis_params) + if created_by_me: + where.append("e.created_by = %s") + params.append(profile_id) + vis_list = _merge_str_any(visibility_any, visibility) if vis_list: ph = ",".join(["%s"] * len(vis_list)) diff --git a/backend/routers/training_planning.py b/backend/routers/training_planning.py index 1eddfea..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 @@ -653,6 +654,129 @@ def _replace_unit_sections(cur, unit_id: int, sections_in: List[Any]): _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 _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], + 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]): if not exercises_in: return @@ -962,6 +1086,10 @@ def list_training_units( end_date: Optional[str] = Query(default=None), status: Optional[str] = Query(default=None), assigned_to_me: bool = Query(default=False), + debrief_pending: bool = Query( + default=False, + description="Nur abgeschlossene Einheiten ohne gesetzte Rückschau (debrief_completed_at IS NULL)", + ), sort: str = Query(default="desc"), limit: Optional[int] = Query(default=None), tenant: TenantContext = Depends(get_tenant_context), @@ -1081,7 +1209,11 @@ def list_training_units( where.append("tu.planned_date <= %s") params.append(end_date) - if status: + if debrief_pending: + where.append("tu.status = %s") + params.append("completed") + where.append("tu.debrief_completed_at IS NULL") + elif status: where.append("tu.status = %s") params.append(status) @@ -1098,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 @@ -1273,6 +1562,8 @@ def create_training_unit(data: dict, tenant: TenantContext = Depends(get_tenant_ elif exercises_in is not None: _insert_sections_from_legacy_exercises(cur, unit_id, exercises_in) + _promote_private_exercises_used_in_unit(cur, unit_id, profile_id, role) + conn.commit() return get_training_unit(unit_id, tenant) @@ -1384,6 +1675,13 @@ def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depen assist_sql = ", assistant_trainer_profile_ids = %s" assist_params.append(na) + debrief_frag = "" + if "debrief_completed" in data and not is_blueprint: + if data.get("debrief_completed") is True: + debrief_frag = ", debrief_completed_at = NOW()" + else: + debrief_frag = ", debrief_completed_at = NULL" + cur.execute( f""" UPDATE training_units SET @@ -1402,6 +1700,7 @@ def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depen updated_at = NOW() {lead_sql} {assist_sql} + {debrief_frag} WHERE id = %s """, ( @@ -1445,6 +1744,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,)) _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() return get_training_unit(unit_id, tenant) @@ -1556,6 +1858,8 @@ def create_training_unit_from_framework_slot(data: dict, tenant: TenantContext = slot_id, ) + _promote_private_exercises_used_in_unit(cur, new_id, profile_id, role) + conn.commit() return get_training_unit(new_id, tenant) @@ -1628,6 +1932,7 @@ def quick_create_training_unit(data: dict, tenant: TenantContext = Depends(get_t if 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() diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 95c5db3..e8b1e43 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -15,6 +15,7 @@ import LoginPage from './pages/LoginPage' import VerifyPage from './pages/VerifyPage' import Dashboard from './pages/Dashboard' import AccountSettingsPage from './pages/AccountSettingsPage' +import SettingsSystemInfoPage from './pages/SettingsSystemInfoPage' import ExercisesListPage from './pages/ExercisesListPage' import ExerciseDetailPage from './pages/ExerciseDetailPage' import ExerciseFormPage from './pages/ExerciseFormPage' @@ -156,6 +157,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/app.css b/frontend/src/app.css index b9f372a..2969738 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -2706,6 +2706,21 @@ a.analysis-split__nav-item { gap: 10px; margin-top: 12px; } +.exercise-search-bar__actions--split { + width: 100%; +} +.exercise-search-bar__actions-main { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; +} +.exercise-mine-toggle--active { + border-color: var(--accent); + background: var(--accent-light); + color: var(--accent-dark); + font-weight: 600; +} .exercise-filter-trigger { display: inline-flex; align-items: center; @@ -3595,6 +3610,246 @@ a.analysis-split__nav-item { } } +/* Dashboard Phase 0: KPI-Kacheln + Trainingsvorschau */ +.dashboard-phase0-kpis { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 156px), 1fr)); + gap: 12px; + margin-bottom: 0; +} + +.dashboard-phase0-kpis__err { + margin: 0 0 10px; + font-size: 0.9rem; + color: var(--danger); +} + +.dashboard-phase0-kpis__loading { + font-size: 0.9rem; + margin: 0 0 10px; +} + +.dashboard-kpi-card { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; + padding: 14px 14px 12px; + background: linear-gradient(165deg, var(--surface2) 0%, var(--surface) 100%); + border: 1px solid var(--border); + border-radius: 14px; + text-decoration: none; + color: inherit; + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.06) inset; + transition: border-color 0.15s, box-shadow 0.15s, transform 0.12s; + min-height: 112px; + box-sizing: border-box; +} + +.dashboard-kpi-card:hover { + border-color: var(--border2); + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.06); + transform: translateY(-1px); +} + +.dashboard-kpi-card--static { + cursor: default; + pointer-events: none; +} + +.dashboard-kpi-card--static:hover { + transform: none; + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.06) inset; + border-color: var(--border); +} + +.dashboard-kpi-card__icon { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: 10px; + background: var(--accent-light); + color: var(--accent-dark); + margin-bottom: 2px; +} + +.dashboard-kpi-card__value { + font-size: 1.75rem; + font-weight: 800; + letter-spacing: -0.03em; + line-height: 1.1; + color: var(--text1); +} + +.dashboard-kpi-card__label { + font-size: 0.8125rem; + font-weight: 600; + color: var(--text2); +} + +.dashboard-kpi-card__hint { + font-size: 0.72rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text3); + margin-top: auto; +} + +@media (max-width: 1023px) { + .dashboard-phase0-kpis { + display: flex; + flex-wrap: nowrap; + gap: 8px; + overflow-x: auto; + overflow-y: hidden; + -webkit-overflow-scrolling: touch; + scroll-snap-type: x proximity; + scrollbar-width: none; + padding: 2px 0 4px; + margin-left: calc(-1 * max(12px, env(safe-area-inset-left, 0px))); + margin-right: calc(-1 * max(12px, env(safe-area-inset-right, 0px))); + padding-left: max(12px, env(safe-area-inset-left, 0px)); + padding-right: max(12px, env(safe-area-inset-right, 0px)); + } + .dashboard-phase0-kpis::-webkit-scrollbar { + display: none; + } + .dashboard-kpi-card { + flex: 0 0 auto; + scroll-snap-align: start; + width: min(132px, 38vw); + min-height: 0; + padding: 10px 10px 8px; + gap: 2px; + } + .dashboard-kpi-card__icon { + width: 32px; + height: 32px; + margin-bottom: 0; + } + .dashboard-kpi-card__icon svg { + width: 18px; + height: 18px; + } + .dashboard-kpi-card__value { + font-size: 1.35rem; + } + .dashboard-kpi-card__label { + font-size: 0.72rem; + line-height: 1.25; + } + .dashboard-kpi-card__hint { + font-size: 0.6rem; + letter-spacing: 0.04em; + } +} + +.dashboard-training-preview-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); + gap: 14px; + align-items: stretch; +} + +.dashboard-preview-card__title { + font-size: 1rem; + font-weight: 700; + margin: 0 0 12px; + padding-bottom: 10px; + border-bottom: 1px solid var(--border); + color: var(--text1); +} + +.dashboard-preview-card__list { + margin: 0; + padding-left: 1.15rem; + color: var(--text2); + font-size: 0.9rem; + line-height: 1.55; +} + +.dashboard-preview-card__list--notes { + font-size: 0.88rem; + line-height: 1.5; +} + +.dashboard-preview-card__list li { + margin-bottom: 0.45rem; +} + +.dashboard-preview-card__link { + font-weight: 600; + color: var(--accent-dark); + text-decoration: none; +} + +.dashboard-preview-card__link:hover { + text-decoration: underline; +} + +.dashboard-preview-card__meta { + color: var(--text3); +} + +.dashboard-preview-card__sub { + display: block; + font-size: 0.82rem; + color: var(--text3); + margin-top: 3px; +} + +.dashboard-preview-card__note-snippet { + margin-top: 5px; + color: var(--text2); +} + +.dashboard-preview-card__empty { + margin: 0; + font-size: 0.9rem; + color: var(--text2); +} + +.dashboard-preview-card__empty a { + color: var(--accent-dark); + font-weight: 600; +} + +.dashboard-preview-card__err { + margin: 0; + font-size: 0.9rem; + color: var(--danger); +} + +.dashboard-sys-card__title { + margin-bottom: 12px; + font-size: 1rem; +} + +.dashboard-sys-card__grid { + display: grid; + grid-template-columns: minmax(0, 120px) 1fr; + gap: 0.5rem 1rem; + align-items: center; + font-size: 0.9rem; +} + +.dashboard-sys-card__pill { + display: inline-block; + padding: 0.2rem 0.5rem; + background: var(--surface2); + color: var(--text1); + border-radius: 6px; + font-size: 0.85rem; +} + +.dashboard-sys-card__pill--accent { + background: var(--accent); + color: #fff; +} + /* --- Übungen: Rich-Text & Kacheln --- */ .rich-text-editor-wrap { border: 1px solid var(--border); 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… +

+
+ ) + } + + if (err) { + return ( +
+

{err}

+
+ ) + } + + if (!items.length) { + return ( +
+

+ Vereinssichtbarkeit in deinen Trainings +

+

+ Keine Übungen in den abgefragten Einheiten, die noch auf Verein gestellt werden müssten. +

+
+ ) + } + + const allPromo = items.filter((i) => i.can_promote) + const allSelected = allPromo.length > 0 && allPromo.every((i) => selected.has(rowKey(i))) + + return ( +
+
+
+

+ Vereinssichtbarkeit in deinen Trainings +

+

+ Übungen in deinen Einheiten, die für den jeweiligen Verein noch nicht sichtbar sind — auf{' '} + Verein setzen oder zur Bearbeitung / Planung springen. +

+
+
+ {allPromo.length ? ( + + ) : null} + +
+
+ +
+ + + + + + + + + + + + {items.map((it) => { + const k = rowKey(it) + const on = selected.has(k) + const visL = VIS_LABELS[it.visibility] || it.visibility + const stL = STATUS_LABELS[it.status] || it.status + const first = it.units && it.units[0] + const restN = (it.units?.length || 0) - 1 + const tool = (it.units || []).map(unitPlanTitle).join('\n') + const cn = (it.target_club_name || '').trim() + return ( + 0 ? tool : undefined} + > + + + + + + + + ) + })} + +
+ ÜbungSichtbarkeitStatusVerein + 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" + > + + + + + + {restN > 0 ? ( + + +{restN} + + ) : null} +
+ ) : ( + '—' + )} +
+
+ + {items.some((i) => !i.can_promote) ? ( +

+ 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. +

+
+ ) +} diff --git a/frontend/src/components/ExercisePickerModal.jsx b/frontend/src/components/ExercisePickerModal.jsx index 658dc2f..bcf9e15 100644 --- a/frontend/src/components/ExercisePickerModal.jsx +++ b/frontend/src/components/ExercisePickerModal.jsx @@ -21,12 +21,17 @@ const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null) const INITIAL_FILTERS = { ...INITIAL_EXERCISE_LIST_FILTERS } +/** Stub-Ziel für API-Validator (mind. Ziel oder Durchführung); Nutzer ergänzt Details in der Übungsbearbeitung. */ +const QUICK_CREATE_GOAL_PLACEHOLDER = + 'Aus der Trainingsplanung angelegt — bitte Ziel und Durchführung in der Übungsbearbeitung ergänzen.' + export default function ExercisePickerModal({ open, onClose, onSelectExercise, multiSelect = false, onSelectExercises = null, + enableQuickCreateDraft = false, }) { const { user } = useAuth() const [catalogs, setCatalogs] = useState({ @@ -49,6 +54,10 @@ export default function ExercisePickerModal({ const [offset, setOffset] = useState(0) const [hasMore, setHasMore] = useState(false) const [multiPicked, setMultiPicked] = useState([]) + const [quickOpen, setQuickOpen] = useState(false) + const [quickTitle, setQuickTitle] = useState('') + const [quickSummary, setQuickSummary] = useState('') + const [quickSaving, setQuickSaving] = useState(false) const toggleMultiPick = (ex) => { setMultiPicked((prev) => @@ -110,6 +119,10 @@ export default function ExercisePickerModal({ setOffset(0) setHasMore(false) setMultiPicked([]) + setQuickOpen(false) + setQuickTitle('') + setQuickSummary('') + setQuickSaving(false) return } setFilters(mergeExerciseListPrefsFromApi(user?.exercise_list_prefs)) @@ -256,6 +269,48 @@ export default function ExercisePickerModal({ const resetFilters = () => setFilters({ ...INITIAL_FILTERS }) + const submitQuickCreate = async () => { + const title = (quickTitle || '').trim() + if (title.length < 3) { + alert('Titel: mindestens 3 Zeichen.') + return + } + const summaryRaw = (quickSummary || '').trim() + setQuickSaving(true) + try { + const created = await api.createExercise({ + title, + summary: summaryRaw || null, + goal: QUICK_CREATE_GOAL_PLACEHOLDER, + execution: null, + visibility: 'private', + status: 'draft', + equipment: [], + focus_areas_multi: [], + training_styles_multi: [], + training_types_multi: [], + target_groups_multi: [], + age_groups: [], + skills: [], + club_id: null, + }) + if (!created?.id) { + throw new Error('Anlegen fehlgeschlagen') + } + if (multiSelect && typeof onSelectExercises === 'function') { + await Promise.resolve(onSelectExercises([created])) + } else if (typeof onSelectExercise === 'function') { + await Promise.resolve(onSelectExercise(created)) + } + onClose() + } catch (e) { + console.error(e) + alert(e.message || 'Übung konnte nicht angelegt werden') + } finally { + setQuickSaving(false) + } + } + if (!open) return null return ( @@ -282,6 +337,75 @@ export default function ExercisePickerModal({ + {enableQuickCreateDraft ? ( +
+ + {quickOpen ? ( +
+

+ Wird mit Sichtbarkeit privat und Status Entwurf gespeichert und + erscheint auf dem Dashboard zum Weiterbearbeiten. Nach dem Speichern wird die Übung direkt in den + Ablauf übernommen. +

+
+ + setQuickTitle(e.target.value)} + autoComplete="off" + minLength={3} + maxLength={300} + placeholder="z. B. Partnerübung Abwehr" + /> +
+
+ +