Merge pull request 'pre-Prod Alpha' (#18) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 35s
Test Suite / pytest-backend (push) Successful in 6s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Successful in 23s

Reviewed-on: #18
This commit is contained in:
Lars 2026-05-07 10:37:21 +02:00
commit c8a08f8a94
17 changed files with 1996 additions and 181 deletions

View File

@ -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;

View File

@ -17,6 +17,7 @@ from pydantic import BaseModel, Field, model_validator
from db import get_db, get_cursor, r2d from db import get_db, get_cursor, r2d
from club_tenancy import ( from club_tenancy import (
assert_valid_governance_visibility, assert_valid_governance_visibility,
can_manage_club_org,
club_admin_shares_club_with_creator, club_admin_shares_club_with_creator,
has_club_role, has_club_role,
is_platform_admin, is_platform_admin,
@ -778,6 +779,10 @@ def bulk_patch_exercises_metadata(
Übungen vollständig durch diese Liste ersetzt (leeres Array entfernt alle). Übungen vollständig durch diese Liste ersetzt (leeres Array entfernt alle).
Berechtigt: Ersteller der jeweiligen Übung oder Plattform-Admin (admin/superadmin). Berechtigt: Ersteller der jeweiligen Übung oder Plattform-Admin (admin/superadmin).
Zusätzlich: Vereinsorga (club_admin) darf **nur** bei reiner Sichtbarkeitsänderung auf ``club``
für den eigenen Verein (`club_id` / aktiver Verein) fremde Übungen freigeben analog
Trainingseinheit-Speichern.
Governance wie bei Einzel-PUT (official nur Plattform-Admin; club mit Mitgliedschaft bzw. Admin). Governance wie bei Einzel-PUT (official nur Plattform-Admin; club mit Mitgliedschaft bzw. Admin).
""" """
profile_id = tenant.profile_id profile_id = tenant.profile_id
@ -861,14 +866,6 @@ def bulk_patch_exercises_metadata(
owner = rowd.get("created_by") owner = rowd.get("created_by")
if owner is not None: if owner is not None:
owner = int(owner) owner = int(owner)
if owner != profile_id and not is_platform_admin(role):
failed.append(
{
"id": ex_id,
"detail": "Keine Berechtigung (nur Ersteller oder Plattform-Admin)",
}
)
continue
ex_vis = (rowd.get("visibility") or "private").strip().lower() ex_vis = (rowd.get("visibility") or "private").strip().lower()
ex_cid_raw = rowd.get("club_id") ex_cid_raw = rowd.get("club_id")
@ -882,11 +879,11 @@ def bulk_patch_exercises_metadata(
if patch_visibility and body.club_id is not None: if patch_visibility and body.club_id is not None:
next_club = int(body.club_id) next_club = int(body.club_id)
if patch_visibility: if patch_visibility and next_vis == "club" and next_club is None:
if next_vis == "club": eff = tenant.effective_club_id
if next_club is None: next_club = int(eff) if eff is not None else None
next_club = tenant.effective_club_id
if next_club is None: if patch_visibility and next_vis == "club" and next_club is None:
failed.append( failed.append(
{ {
"id": ex_id, "id": ex_id,
@ -894,6 +891,33 @@ def bulk_patch_exercises_metadata(
} }
) )
continue 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:
gov_club = next_club if next_vis == "club" else None gov_club = next_club if next_vis == "club" else None
try: try:
assert_valid_governance_visibility(cur, profile_id, role, next_vis, gov_club) assert_valid_governance_visibility(cur, profile_id, role, next_vis, gov_club)
@ -1009,6 +1033,10 @@ def list_exercises(
default=False, default=False,
description="Archivierte einbeziehen; Standard false (außer Statusfilter enthält archived)", 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), tenant: TenantContext = Depends(get_tenant_context),
): ):
""" """
@ -1036,6 +1064,10 @@ def list_exercises(
where.append(vis_sql) where.append(vis_sql)
params.extend(vis_params) 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) vis_list = _merge_str_any(visibility_any, visibility)
if vis_list: if vis_list:
ph = ",".join(["%s"] * len(vis_list)) ph = ",".join(["%s"] * len(vis_list))

View File

@ -4,6 +4,7 @@ und wiederverwendbare Trainingsvorlagen (Sektions-Gliederung).
Governance: Sichtbarkeit wie Übungen (private / club / official); Schreiben nur Ersteller oder Plattform-Admin. 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 typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query 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")) _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]): def _insert_sections_from_legacy_exercises(cur, unit_id: int, exercises_in: List[Any]):
if not exercises_in: if not exercises_in:
return return
@ -962,6 +1086,10 @@ def list_training_units(
end_date: Optional[str] = Query(default=None), end_date: Optional[str] = Query(default=None),
status: Optional[str] = Query(default=None), status: Optional[str] = Query(default=None),
assigned_to_me: bool = Query(default=False), 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"), sort: str = Query(default="desc"),
limit: Optional[int] = Query(default=None), limit: Optional[int] = Query(default=None),
tenant: TenantContext = Depends(get_tenant_context), tenant: TenantContext = Depends(get_tenant_context),
@ -1081,7 +1209,11 @@ def list_training_units(
where.append("tu.planned_date <= %s") where.append("tu.planned_date <= %s")
params.append(end_date) 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") where.append("tu.status = %s")
params.append(status) params.append(status)
@ -1098,6 +1230,163 @@ def list_training_units(
return [r2d(r) for r in rows] 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}") @router.get("/training-units/{unit_id}")
def get_training_unit(unit_id: int, tenant: TenantContext = Depends(get_tenant_context)): def get_training_unit(unit_id: int, tenant: TenantContext = Depends(get_tenant_context)):
profile_id = tenant.profile_id 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: elif exercises_in is not None:
_insert_sections_from_legacy_exercises(cur, unit_id, exercises_in) _insert_sections_from_legacy_exercises(cur, unit_id, exercises_in)
_promote_private_exercises_used_in_unit(cur, unit_id, profile_id, role)
conn.commit() conn.commit()
return get_training_unit(unit_id, tenant) return get_training_unit(unit_id, tenant)
@ -1384,6 +1675,13 @@ def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depen
assist_sql = ", assistant_trainer_profile_ids = %s" assist_sql = ", assistant_trainer_profile_ids = %s"
assist_params.append(na) 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( cur.execute(
f""" f"""
UPDATE training_units SET UPDATE training_units SET
@ -1402,6 +1700,7 @@ def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depen
updated_at = NOW() updated_at = NOW()
{lead_sql} {lead_sql}
{assist_sql} {assist_sql}
{debrief_frag}
WHERE id = %s 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,)) cur.execute("DELETE FROM training_unit_sections WHERE training_unit_id = %s", (unit_id,))
_insert_sections_from_legacy_exercises(cur, unit_id, data["exercises"] or []) _insert_sections_from_legacy_exercises(cur, unit_id, data["exercises"] or [])
if content_handled or "sections" in data or "exercises" in data:
_promote_private_exercises_used_in_unit(cur, unit_id, profile_id, role)
conn.commit() conn.commit()
return get_training_unit(unit_id, tenant) return get_training_unit(unit_id, tenant)
@ -1556,6 +1858,8 @@ def create_training_unit_from_framework_slot(data: dict, tenant: TenantContext =
slot_id, slot_id,
) )
_promote_private_exercises_used_in_unit(cur, new_id, profile_id, role)
conn.commit() conn.commit()
return get_training_unit(new_id, tenant) return get_training_unit(new_id, tenant)
@ -1628,6 +1932,7 @@ def quick_create_training_unit(data: dict, tenant: TenantContext = Depends(get_t
if tpl_id_safe: if tpl_id_safe:
_instantiate_from_template(cur, unit_id, tpl_id_safe) _instantiate_from_template(cur, unit_id, tpl_id_safe)
_promote_private_exercises_used_in_unit(cur, unit_id, profile_id, role)
conn.commit() conn.commit()

View File

@ -15,6 +15,7 @@ import LoginPage from './pages/LoginPage'
import VerifyPage from './pages/VerifyPage' import VerifyPage from './pages/VerifyPage'
import Dashboard from './pages/Dashboard' import Dashboard from './pages/Dashboard'
import AccountSettingsPage from './pages/AccountSettingsPage' import AccountSettingsPage from './pages/AccountSettingsPage'
import SettingsSystemInfoPage from './pages/SettingsSystemInfoPage'
import ExercisesListPage from './pages/ExercisesListPage' import ExercisesListPage from './pages/ExercisesListPage'
import ExerciseDetailPage from './pages/ExerciseDetailPage' import ExerciseDetailPage from './pages/ExerciseDetailPage'
import ExerciseFormPage from './pages/ExerciseFormPage' import ExerciseFormPage from './pages/ExerciseFormPage'
@ -156,6 +157,7 @@ function AppRoutes() {
<Route index element={<Dashboard />} /> <Route index element={<Dashboard />} />
<Route path="profile" element={<Navigate to="/settings" replace />} /> <Route path="profile" element={<Navigate to="/settings" replace />} />
<Route path="settings" element={<AccountSettingsPage />} /> <Route path="settings" element={<AccountSettingsPage />} />
<Route path="settings/system" element={<SettingsSystemInfoPage />} />
<Route path="exercises"> <Route path="exercises">
<Route index element={<ExercisesListPage />} /> <Route index element={<ExercisesListPage />} />
<Route path="new" element={<ExerciseFormPage />} /> <Route path="new" element={<ExerciseFormPage />} />

View File

@ -2706,6 +2706,21 @@ a.analysis-split__nav-item {
gap: 10px; gap: 10px;
margin-top: 12px; 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 { .exercise-filter-trigger {
display: inline-flex; display: inline-flex;
align-items: center; 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 --- */ /* --- Übungen: Rich-Text & Kacheln --- */
.rich-text-editor-wrap { .rich-text-editor-wrap {
border: 1px solid var(--border); border: 1px solid var(--border);

View File

@ -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 (
<div className="card dashboard-preview-card" style={{ marginTop: '1rem' }}>
<p className="muted" style={{ margin: 0 }}>
Vereinsfreigaben werden geladen
</p>
</div>
)
}
if (err) {
return (
<div className="card dashboard-preview-card" style={{ marginTop: '1rem' }} role="alert">
<p style={{ margin: 0, color: 'var(--danger, #c0392b)' }}>{err}</p>
</div>
)
}
if (!items.length) {
return (
<div className="card dashboard-preview-card" style={{ marginTop: '1rem' }}>
<h3 className="dashboard-preview-card__title" style={{ marginTop: 0 }}>
Vereinssichtbarkeit in deinen Trainings
</h3>
<p className="dashboard-preview-card__empty" style={{ margin: 0 }}>
Keine Übungen in den abgefragten Einheiten, die noch auf Verein gestellt werden müssten.
</p>
</div>
)
}
const allPromo = items.filter((i) => i.can_promote)
const allSelected = allPromo.length > 0 && allPromo.every((i) => selected.has(rowKey(i)))
return (
<div className="card dashboard-preview-card dashboard-vis-queue" style={{ marginTop: '1rem' }}>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: '12px',
}}
>
<div>
<h3 className="dashboard-preview-card__title" style={{ marginTop: 0 }}>
Vereinssichtbarkeit in deinen Trainings
</h3>
<p
className="muted"
style={{ margin: '0.35rem 0 0', fontSize: '0.88rem', lineHeight: 1.45, maxWidth: '52rem' }}
>
Übungen in deinen Einheiten, die für den jeweiligen Verein noch nicht sichtbar sind auf{' '}
<strong>Verein</strong> setzen oder zur Bearbeitung / Planung springen.
</p>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center' }}>
{allPromo.length ? (
<button type="button" className="btn btn-secondary" onClick={toggleAllPromotable}>
{allSelected ? 'Auswahl leeren' : 'Alle wählbar'}
</button>
) : null}
<button
type="button"
className="btn btn-primary"
disabled={!promotableSelected.length || busy}
onClick={promoteSelected}
>
{busy ? 'Speichern…' : `Freigeben (${promotableSelected.length})`}
</button>
</div>
</div>
<div style={{ marginTop: '0.75rem', overflowX: 'auto' }}>
<table
className="dashboard-vis-queue__table"
style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.86rem' }}
>
<thead>
<tr
style={{
textAlign: 'left',
borderBottom: '1px solid var(--border2)',
color: 'var(--text3)',
fontSize: '0.78rem',
}}
>
<th style={{ width: 32, padding: '6px 4px' }} aria-label="Auswahl" />
<th style={{ padding: '6px 6px' }}>Übung</th>
<th style={{ padding: '6px 6px', whiteSpace: 'nowrap' }}>Sichtbarkeit</th>
<th style={{ padding: '6px 6px', whiteSpace: 'nowrap' }}>Status</th>
<th style={{ padding: '6px 6px' }}>Verein</th>
<th style={{ padding: '6px 4px', width: 76 }} title="Planung und Durchführung">
Kontext
</th>
</tr>
</thead>
<tbody>
{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 (
<tr
key={k}
style={{ borderBottom: '1px solid var(--border2)' }}
title={restN > 0 ? tool : undefined}
>
<td style={{ padding: '6px 4px', verticalAlign: 'top' }}>
<input
type="checkbox"
checked={on}
disabled={!it.can_promote}
onChange={() => toggle(k)}
title={it.can_promote ? 'Zur Freigabe wählen' : 'Keine Berechtigung für diese Freigabe'}
aria-label={`${it.title} auswählen`}
/>
</td>
<td style={{ padding: '6px 6px', verticalAlign: 'top', minWidth: 140 }}>
<Link
to={`/exercises/${it.exercise_id}/edit`}
className="dashboard-preview-card__link"
style={{ fontWeight: 600 }}
>
{it.title}
</Link>
</td>
<td style={{ padding: '6px 6px', verticalAlign: 'top', whiteSpace: 'nowrap' }}>{visL}</td>
<td style={{ padding: '6px 6px', verticalAlign: 'top', whiteSpace: 'nowrap' }}>{stL}</td>
<td style={{ padding: '6px 6px', verticalAlign: 'top', maxWidth: 200 }}>
<span title={cn}>{cn.length > 28 ? `${cn.slice(0, 28)}` : cn || '—'}</span>
</td>
<td style={{ padding: '6px 4px', verticalAlign: 'top' }}>
{first ? (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
flexWrap: 'wrap',
justifyContent: 'flex-end',
}}
>
<Link
to={`/planning?unit=${first.id}`}
className="btn-ghost"
style={{
padding: '2px 4px',
display: 'inline-flex',
borderRadius: 6,
textDecoration: 'none',
color: 'var(--text1)',
}}
title={
restN > 0
? `Planung (${unitPlanTitle(first)}; +${restN} weitere — siehe Tooltip ganze Zeile)`
: `Planung öffnen: ${unitPlanTitle(first)}`
}
aria-label="Planung öffnen"
>
<CalendarDays size={16} strokeWidth={2} aria-hidden />
</Link>
<Link
to={`/planning/run/${first.id}`}
className="btn-ghost"
style={{
padding: '2px 4px',
display: 'inline-flex',
borderRadius: 6,
textDecoration: 'none',
color: 'var(--text1)',
}}
title="Durchführung / Ablauf"
aria-label="Durchführung öffnen"
>
<ClipboardList size={16} strokeWidth={2} aria-hidden />
</Link>
{restN > 0 ? (
<span className="muted" style={{ fontSize: '0.72rem' }} title={tool}>
+{restN}
</span>
) : null}
</div>
) : (
'—'
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
{items.some((i) => !i.can_promote) ? (
<p className="muted" style={{ margin: '0.5rem 0 0', fontSize: '0.82rem', lineHeight: 1.45 }}>
Ausgegraute Kästchen: keine direkte Freigabe-Berechtigung Vereinsorga kontaktieren oder die Einheit in
der Planung speichern (dann ggf. automatische Vereinsfreigabe).
</p>
) : null}
<p className="muted" style={{ margin: '0.5rem 0 0', fontSize: '0.8rem', lineHeight: 1.45 }}>
Mehrere Termine: Kalender-Icon nutzt den frühesten; +N listet alle Daten und Gruppen im Tooltip.
</p>
{msg ? (
<p
style={{ margin: '0.65rem 0 0', fontSize: '0.84rem', color: 'var(--danger, #c0392b)' }}
role="alert"
>
{msg}
</p>
) : null}
<p style={{ margin: '0.65rem 0 0', fontSize: '0.82rem' }}>
<Link to="/planning">Zur Trainingsplanung</Link>
<span className="muted"> · Zeitraum ca. 45 Tage zurück bis 1 Jahr voraus; bis zu 100 Einheiten.</span>
</p>
</div>
)
}

View File

@ -21,12 +21,17 @@ const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null)
const INITIAL_FILTERS = { ...INITIAL_EXERCISE_LIST_FILTERS } 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({ export default function ExercisePickerModal({
open, open,
onClose, onClose,
onSelectExercise, onSelectExercise,
multiSelect = false, multiSelect = false,
onSelectExercises = null, onSelectExercises = null,
enableQuickCreateDraft = false,
}) { }) {
const { user } = useAuth() const { user } = useAuth()
const [catalogs, setCatalogs] = useState({ const [catalogs, setCatalogs] = useState({
@ -49,6 +54,10 @@ export default function ExercisePickerModal({
const [offset, setOffset] = useState(0) const [offset, setOffset] = useState(0)
const [hasMore, setHasMore] = useState(false) const [hasMore, setHasMore] = useState(false)
const [multiPicked, setMultiPicked] = useState([]) 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) => { const toggleMultiPick = (ex) => {
setMultiPicked((prev) => setMultiPicked((prev) =>
@ -110,6 +119,10 @@ export default function ExercisePickerModal({
setOffset(0) setOffset(0)
setHasMore(false) setHasMore(false)
setMultiPicked([]) setMultiPicked([])
setQuickOpen(false)
setQuickTitle('')
setQuickSummary('')
setQuickSaving(false)
return return
} }
setFilters(mergeExerciseListPrefsFromApi(user?.exercise_list_prefs)) setFilters(mergeExerciseListPrefsFromApi(user?.exercise_list_prefs))
@ -256,6 +269,48 @@ export default function ExercisePickerModal({
const resetFilters = () => setFilters({ ...INITIAL_FILTERS }) 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 if (!open) return null
return ( return (
@ -282,6 +337,75 @@ export default function ExercisePickerModal({
</button> </button>
</div> </div>
{enableQuickCreateDraft ? (
<div
style={{
padding: '10px 1rem 12px',
borderBottom: '1px solid var(--border)',
flexShrink: 0,
background: 'var(--surface2)',
}}
>
<button
type="button"
className="btn btn-secondary"
style={{ width: '100%' }}
onClick={() => setQuickOpen((v) => !v)}
aria-expanded={quickOpen}
>
{quickOpen ? 'Neue Übung ausblenden' : 'Neue Übung anlegen (Entwurf, privat)'}
</button>
{quickOpen ? (
<div style={{ marginTop: '12px', display: 'grid', gap: '10px' }}>
<p style={{ margin: 0, fontSize: '13px', color: 'var(--text2)', lineHeight: 1.45 }}>
Wird mit Sichtbarkeit <strong>privat</strong> und Status <strong>Entwurf</strong> gespeichert und
erscheint auf dem Dashboard zum Weiterbearbeiten. Nach dem Speichern wird die Übung direkt in den
Ablauf übernommen.
</p>
<div>
<label className="form-label" htmlFor="ex-picker-quick-title">
Titel
</label>
<input
id="ex-picker-quick-title"
type="text"
className="form-input"
value={quickTitle}
onChange={(e) => setQuickTitle(e.target.value)}
autoComplete="off"
minLength={3}
maxLength={300}
placeholder="z.B. Partnerübung Abwehr"
/>
</div>
<div>
<label className="form-label" htmlFor="ex-picker-quick-summary">
Kurzbeschreibung
</label>
<textarea
id="ex-picker-quick-summary"
className="form-input"
rows={3}
value={quickSummary}
onChange={(e) => setQuickSummary(e.target.value)}
placeholder="Optional: grobe Idee, Kontext aus der Planung …"
/>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', justifyContent: 'flex-end' }}>
<button
type="button"
className="btn btn-primary"
disabled={quickSaving || (quickTitle || '').trim().length < 3}
onClick={submitQuickCreate}
>
{quickSaving ? 'Wird angelegt…' : 'Entwurf anlegen und übernehmen'}
</button>
</div>
</div>
) : null}
</div>
) : null}
<div style={{ padding: '0 1rem 0.75rem', borderBottom: '1px solid var(--border)', flexShrink: 0 }}> <div style={{ padding: '0 1rem 0.75rem', borderBottom: '1px solid var(--border)', flexShrink: 0 }}>
<div style={{ display: 'grid', gap: '0.65rem' }}> <div style={{ display: 'grid', gap: '0.65rem' }}>
<div> <div>

View File

@ -0,0 +1,280 @@
import React, { useCallback, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import api from '../utils/api'
const VIS_LABELS = {
private: 'Privat',
club: 'Verein',
official: 'Offiziell',
}
function collectExerciseRows(sections) {
const map = new Map()
for (const sec of sections || []) {
for (const it of sec.items || []) {
if (it.item_type === 'note') continue
const id = Number(it.exercise_id)
if (!Number.isFinite(id) || id < 1) continue
if (!map.has(id)) {
map.set(id, it)
}
}
}
return [...map.entries()].map(([id, it]) => ({
id,
title: it.exercise_title || `Übung #${id}`,
visibility: it.exercise_visibility,
clubId: it.exercise_club_id != null ? Number(it.exercise_club_id) : null,
createdBy: it.exercise_created_by != null ? Number(it.exercise_created_by) : null,
status: it.exercise_status,
}))
}
function needsClubForTarget(row, targetClubId) {
if (targetClubId == null || !Number.isFinite(Number(targetClubId))) return false
const vis = String(row.visibility || 'private').toLowerCase()
if (vis === 'official') return false
const tc = Number(targetClubId)
if (vis === 'private') return true
if (vis === 'club') {
if (row.clubId == null) return true
return row.clubId !== tc
}
return false
}
function userMayPromote(user, targetClubId, createdBy) {
if (!user || targetClubId == null) return false
const role = String(user.role || '').toLowerCase()
if (role === 'admin' || role === 'superadmin') return true
if (createdBy != null && Number(createdBy) === Number(user.id)) return true
const row = (user.clubs || []).find((c) => Number(c.id) === Number(targetClubId))
if (!row || !Array.isArray(row.roles)) return false
return row.roles.includes('club_admin')
}
/**
* Listen-Panel im Trainingsplan: Übungen, die für die gewählte Gruppe noch nicht vereinsweit sichtbar sind,
* und Freigabe auf Verein (API: PUT / bulk-metadata).
*/
export default function TrainingPlanExerciseVisibilityPanel({
sections,
targetClubId,
user,
onMetaRefresh,
}) {
const [busyId, setBusyId] = useState(null)
const [bulkBusy, setBulkBusy] = useState(false)
const [message, setMessage] = useState(null)
const rows = useMemo(() => collectExerciseRows(sections), [sections])
const { pending, okCount } = useMemo(() => {
if (targetClubId == null || !Number.isFinite(Number(targetClubId))) {
return { pending: [], okCount: 0 }
}
const pending = []
let okCount = 0
for (const r of rows) {
if (needsClubForTarget(r, targetClubId)) pending.push(r)
else okCount += 1
}
return { pending, okCount }
}, [rows, targetClubId])
const promotableIds = useMemo(
() => pending.filter((r) => userMayPromote(user, targetClubId, r.createdBy)).map((r) => r.id),
[pending, targetClubId, user]
)
const applyClubVisibility = useCallback(
async (exerciseIds) => {
if (!exerciseIds.length || targetClubId == null) return
setMessage(null)
const res = await api.bulkPatchExercisesMetadata({
exercise_ids: exerciseIds,
visibility: 'club',
club_id: targetClubId,
})
const failed = res?.failed || []
const updatedN = Number(res?.updated_count || 0)
if (updatedN > 0 && onMetaRefresh) {
await onMetaRefresh()
}
if (failed.length) {
const first = failed[0]?.detail || 'Unbekannter Fehler'
setMessage(
failed.length === 1
? String(first)
: `${failed.length} Übungen nicht geändert: ${first}`
)
}
},
[targetClubId, onMetaRefresh]
)
const onPromoteOne = useCallback(
async (id) => {
setBusyId(id)
setMessage(null)
try {
await applyClubVisibility([id])
} catch (e) {
setMessage(e?.message || String(e))
} finally {
setBusyId(null)
}
},
[applyClubVisibility]
)
const onPromoteAll = useCallback(async () => {
if (!promotableIds.length) return
setBulkBusy(true)
setMessage(null)
try {
await applyClubVisibility(promotableIds)
} catch (e) {
setMessage(e?.message || String(e))
} finally {
setBulkBusy(false)
}
}, [applyClubVisibility, promotableIds])
if (!rows.length) return null
return (
<div
className="training-planning-template-panel training-plan-visibility-panel no-print"
style={{ marginBottom: '1.15rem' }}
>
<span className="training-planning-template-panel__label">Sichtbarkeit für den Verein</span>
<p className="training-planning-template-panel__help" style={{ marginTop: 0 }}>
Übungen mit Sichtbarkeit Privat oder einem anderen Verein sieht das Team bei der Durchführung
nicht. Hier können Sie sie auf <strong>Verein</strong> setzen (gleiche Logik wie beim Speichern der
Einheit).
</p>
{targetClubId == null || !Number.isFinite(Number(targetClubId)) ? (
<p style={{ margin: '0.35rem 0 0', fontSize: '0.82rem', color: 'var(--text3)', lineHeight: 1.45 }}>
Wählen Sie eine Trainingsgruppe, um passende Freigaben anzuzeigen.
</p>
) : null}
{targetClubId != null && Number.isFinite(Number(targetClubId)) && !pending.length && rows.length ? (
<p
style={{
margin: '0.5rem 0 0',
fontSize: '0.88rem',
color: 'var(--text2)',
lineHeight: 1.45,
}}
>
Alle {rows.length} {rows.length === 1 ? 'Übung ist' : 'Übungen sind'} für diesen Verein in der
Durchführung sichtbar (oder offiziell).
</p>
) : null}
{targetClubId != null && Number.isFinite(Number(targetClubId)) && pending.length ? (
<>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
gap: '10px',
marginTop: '0.65rem',
}}
>
<button
type="button"
className="btn btn-secondary"
disabled={!promotableIds.length || bulkBusy}
onClick={onPromoteAll}
>
{bulkBusy ? 'Speichern…' : `Alle auf Verein (${promotableIds.length})`}
</button>
{okCount > 0 ? (
<span style={{ fontSize: '0.82rem', color: 'var(--text3)' }}>
{okCount} weitere {okCount === 1 ? 'Übung' : 'Übungen'} bereits passend
</span>
) : null}
</div>
<ul
style={{
listStyle: 'none',
margin: '0.75rem 0 0',
padding: 0,
display: 'flex',
flexDirection: 'column',
gap: '8px',
}}
>
{pending.map((r) => {
const vis = String(r.visibility || 'private').toLowerCase()
const visLabel = VIS_LABELS[vis] || vis
const may = userMayPromote(user, targetClubId, r.createdBy)
const loading = busyId === r.id
return (
<li
key={r.id}
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
gap: '10px',
padding: '10px 12px',
borderRadius: '10px',
border: '1px solid var(--border2)',
background: 'var(--surface)',
}}
>
<div style={{ flex: '1 1 160px', minWidth: 0 }}>
<Link to={`/exercises/${r.id}`} style={{ fontWeight: 600, fontSize: '0.9rem' }}>
{r.title}
</Link>
<div style={{ fontSize: '0.78rem', color: 'var(--text3)', marginTop: '4px' }}>
Aktuell: {visLabel}
{vis === 'club' && r.clubId != null && r.clubId !== Number(targetClubId)
? ` · anderer Verein (#${r.clubId})`
: ''}
</div>
</div>
<button
type="button"
className="btn btn-primary"
disabled={!may || loading || bulkBusy}
title={
may
? 'Sichtbarkeit auf Verein setzen'
: 'Nur Ersteller, Plattform-Admin oder Vereinsorga (Admin im Verein)'
}
onClick={() => onPromoteOne(r.id)}
>
{loading ? '…' : 'Auf Verein'}
</button>
</li>
)
})}
</ul>
{pending.some((r) => !userMayPromote(user, targetClubId, r.createdBy)) ? (
<p className="training-planning-template-panel__help" style={{ marginTop: '0.65rem' }}>
Einige Einträge können Sie nicht selbst freigeben: Denken Sie an die Vereinsorga oder speichern Sie
die Einheit bei ausreichender Berechtigung werden private Übungen dann automatisch mitgeführt.
</p>
) : null}
</>
) : null}
{message ? (
<p
role="alert"
style={{
margin: '0.65rem 0 0',
fontSize: '0.84rem',
color: 'var(--danger, #c0392b)',
lineHeight: 1.45,
}}
>
{message}
</p>
) : null}
</div>
)
}

View File

@ -1,4 +1,5 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import api from '../utils/api' import api from '../utils/api'
@ -411,6 +412,11 @@ function AccountSettingsPage() {
</button> </button>
</form> </form>
</div> </div>
<p className="muted" style={{ marginTop: '1.75rem', fontSize: '0.875rem', lineHeight: 1.5 }}>
<Link to="/settings/system">Technische Systeminformationen</Link>
{' — App-Version, Build, Umgebung, Datenbankschema'}
</p>
</div> </div>
) )
} }

View File

@ -1,8 +1,10 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { CalendarCheck, ClipboardList, FilePenLine, Library } from 'lucide-react'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import api from '../utils/api' import api from '../utils/api'
import EmailVerificationBanner from '../components/EmailVerificationBanner' import EmailVerificationBanner from '../components/EmailVerificationBanner'
import DashboardTrainingVisibilityWidget from '../components/DashboardTrainingVisibilityWidget'
function unitWhenLabel(u) { function unitWhenLabel(u) {
const d = u.planned_date ? String(u.planned_date).slice(0, 10) : '' const d = u.planned_date ? String(u.planned_date).slice(0, 10) : ''
@ -11,12 +13,18 @@ function unitWhenLabel(u) {
return bits.length ? bits.join(' · ') : 'Termin' return bits.length ? bits.join(' · ') : 'Termin'
} }
function formatCappedCount(n, capped) {
if (capped && n >= 1) return `${n}+`
return String(n)
}
function Dashboard() { function Dashboard() {
const [version, setVersion] = useState(null)
const [profile, setProfile] = useState(null) const [profile, setProfile] = useState(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [trainingHome, setTrainingHome] = useState(null) const [trainingHome, setTrainingHome] = useState(null)
const [trainingHomeErr, setTrainingHomeErr] = useState(null) const [trainingHomeErr, setTrainingHomeErr] = useState(null)
const [phase0Stats, setPhase0Stats] = useState(null)
const [phase0Err, setPhase0Err] = useState(null)
const { user } = useAuth() const { user } = useAuth()
useEffect(() => { useEffect(() => {
@ -34,27 +42,27 @@ function Dashboard() {
setTrainingHomeErr(null) setTrainingHomeErr(null)
try { try {
const today = new Date().toISOString().slice(0, 10) const today = new Date().toISOString().slice(0, 10)
const [upcomingRaw, recentRaw, plannedPool] = await Promise.all([ const [upcomingRaw, reviewPendingRaw, plannedPool] = await Promise.all([
api.listTrainingUnits({ api.listTrainingUnits({
assigned_to_me: true, assigned_to_me: true,
status: 'planned', status: 'planned',
start_date: today, start_date: today,
sort: 'asc', sort: 'asc',
limit: 8 limit: 8,
}), }),
api.listTrainingUnits({ api.listTrainingUnits({
assigned_to_me: true, assigned_to_me: true,
status: 'completed', debrief_pending: true,
sort: 'desc', sort: 'desc',
limit: 6 limit: 8,
}), }),
api.listTrainingUnits({ api.listTrainingUnits({
assigned_to_me: true, assigned_to_me: true,
status: 'planned', status: 'planned',
start_date: today, start_date: today,
sort: 'asc', sort: 'asc',
limit: 40 limit: 40,
}) }),
]) ])
const noteHits = (plannedPool || []).filter((u) => { const noteHits = (plannedPool || []).filter((u) => {
const tn = (u.trainer_notes || '').trim() const tn = (u.trainer_notes || '').trim()
@ -64,8 +72,8 @@ function Dashboard() {
if (!cancelled) { if (!cancelled) {
setTrainingHome({ setTrainingHome({
upcoming: Array.isArray(upcomingRaw) ? upcomingRaw : [], upcoming: Array.isArray(upcomingRaw) ? upcomingRaw : [],
recent: Array.isArray(recentRaw) ? recentRaw : [], reviewPending: Array.isArray(reviewPendingRaw) ? reviewPendingRaw : [],
plannedWithNotes: noteHits plannedWithNotes: noteHits,
}) })
} }
} catch (e) { } catch (e) {
@ -81,13 +89,63 @@ function Dashboard() {
} }
}, [user?.id]) }, [user?.id])
useEffect(() => {
if (!user?.id) {
setPhase0Stats(null)
setPhase0Err(null)
return undefined
}
let cancelled = false
;(async () => {
setPhase0Err(null)
try {
const year = new Date().getFullYear()
const yearStart = `${year}-01-01`
const yearEnd = `${year}-12-31`
const [draftList, mineList, ytdCompleted] = await Promise.all([
api.listExercises({ created_by_me: true, status: 'draft', limit: 100 }),
api.listExercises({ created_by_me: true, limit: 100 }),
api.listTrainingUnits({
assigned_to_me: true,
status: 'completed',
start_date: yearStart,
end_date: yearEnd,
limit: 250,
sort: 'desc',
}),
])
if (!cancelled) {
const drafts = Array.isArray(draftList) ? draftList : []
setPhase0Stats({
year,
draftCount: drafts.length,
draftCapped: drafts.length >= 100,
draftPreview: drafts.slice(0, 8).map((ex) => ({
id: ex.id,
title: ex.title || `Übung #${ex.id}`,
})),
mineCount: Array.isArray(mineList) ? mineList.length : 0,
mineCapped: Array.isArray(mineList) && mineList.length >= 100,
ytdCompletedCount: Array.isArray(ytdCompleted) ? ytdCompleted.length : 0,
ytdCapped: Array.isArray(ytdCompleted) && ytdCompleted.length >= 250,
})
}
} catch (e) {
if (!cancelled) {
console.error('Dashboard Übungs-Kennzahlen:', e)
setPhase0Err(e.message || 'Konnte Übungs-Kennzahlen nicht laden')
setPhase0Stats(null)
}
}
})()
return () => {
cancelled = true
}
}, [user?.id])
const loadData = async () => { const loadData = async () => {
try { try {
const [versionData, profileData] = await Promise.all([ const profileData = await api.getCurrentProfile()
api.getVersion(),
api.getCurrentProfile()
])
setVersion(versionData)
setProfile(profileData) setProfile(profileData)
} catch (err) { } catch (err) {
console.error('Failed to load data:', err) console.error('Failed to load data:', err)
@ -105,6 +163,9 @@ function Dashboard() {
) )
} }
const draftsHref = '/exercises?status=draft&mine=1'
const mineHref = '/exercises?mine=1'
return ( return (
<div className="app-page dashboard-page"> <div className="app-page dashboard-page">
<div className="dashboard-greeting"> <div className="dashboard-greeting">
@ -113,39 +174,128 @@ function Dashboard() {
Dashboard Dashboard
</h1> </h1>
<p className="muted" style={{ marginTop: 0 }}> <p className="muted" style={{ marginTop: 0 }}>
Willkommen, {user?.name || user?.email}! Shinkan unterstützt dich bei Übungen, Planung und Vereinsstruktur. Willkommen, {user?.name || user?.email}! Shinkan unterstützt dich bei Übungen, Planung und
Vereinsstruktur.
</p> </p>
</div> </div>
</div> </div>
{profile && <EmailVerificationBanner profile={profile} />} {profile && <EmailVerificationBanner profile={profile} />}
{user?.id && ( {user?.id ? (
<div <>
className="dashboard-training-grid" <section className="dashboard-section" aria-labelledby="dash-phase0-title">
style={{ <div className="dashboard-section__header">
display: 'grid', <div className="dashboard-section__headline">
gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 280px), 1fr))', <h2 id="dash-phase0-title" className="dashboard-section__title">
gap: '1rem', Kurzüberblick
alignItems: 'stretch', </h2>
marginBottom: '1.5rem', <p className="dashboard-section__description">
}} Trainings dieses Kalenderjahres beziehen sich auf den <strong>geplanten Termin</strong> (nicht
> zwingend Abschlussdatum). Zahlen können bei sehr vielen Einträgen mit + enden.
<div className="card"> </p>
<h3 style={{ fontSize: '1.05rem', marginBottom: '0.65rem' }}>Deine nächsten Trainings</h3> </div>
</div>
{phase0Err ? (
<p className="dashboard-phase0-kpis__err" role="alert">
{phase0Err}
</p>
) : null}
{!phase0Err && !phase0Stats ? (
<div className="dashboard-phase0-kpis__loading muted">Zahlen werden geladen</div>
) : null}
{!phase0Err && phase0Stats ? (
<div className="dashboard-phase0-kpis">
<Link className="dashboard-kpi-card" to={draftsHref}>
<span className="dashboard-kpi-card__icon" aria-hidden>
<FilePenLine size={22} strokeWidth={2} />
</span>
<span className="dashboard-kpi-card__value">
{formatCappedCount(phase0Stats.draftCount, phase0Stats.draftCapped)}
</span>
<span className="dashboard-kpi-card__label">Übungs-Entwürfe</span>
<span className="dashboard-kpi-card__hint">finalisieren</span>
</Link>
<Link className="dashboard-kpi-card" to={mineHref}>
<span className="dashboard-kpi-card__icon" aria-hidden>
<Library size={22} strokeWidth={2} />
</span>
<span className="dashboard-kpi-card__value">
{formatCappedCount(phase0Stats.mineCount, phase0Stats.mineCapped)}
</span>
<span className="dashboard-kpi-card__label">Meine Übungen</span>
<span className="dashboard-kpi-card__hint">alle Status</span>
</Link>
<div className="dashboard-kpi-card dashboard-kpi-card--static">
<span className="dashboard-kpi-card__icon" aria-hidden>
<ClipboardList size={22} strokeWidth={2} />
</span>
<span className="dashboard-kpi-card__value">
{formatCappedCount(phase0Stats.ytdCompletedCount, phase0Stats.ytdCapped)}
</span>
<span className="dashboard-kpi-card__label">Gehalten {phase0Stats.year}</span>
<span className="dashboard-kpi-card__hint">abrechnungsnah</span>
</div>
</div>
) : null}
{!phase0Err && phase0Stats?.draftPreview?.length ? (
<div className="card dashboard-draft-preview" style={{ marginTop: '1rem' }}>
<h3 className="dashboard-preview-card__title" style={{ marginTop: 0 }}>
Entwürfe fertigstellen
</h3>
<p className="muted" style={{ marginTop: '0.35rem', marginBottom: '0.85rem', fontSize: '0.92rem' }}>
Private Übungs-Entwürfe (z.B. aus der Planung) Ziel, Durchführung und Details in der Bearbeitung
ergänzen.
</p>
<ul className="dashboard-preview-card__list">
{phase0Stats.draftPreview.map((ex) => (
<li key={ex.id}>
<Link to={`/exercises/${ex.id}/edit`} className="dashboard-preview-card__link">
{ex.title}
</Link>
</li>
))}
</ul>
<p style={{ margin: '0.75rem 0 0', fontSize: '0.86rem' }}>
<Link to={draftsHref}>Alle Entwürfe in der Übersicht</Link>
</p>
</div>
) : null}
</section>
<section className="dashboard-section" aria-labelledby="dash-trainings-title">
<div className="dashboard-section__header">
<div className="dashboard-section__headline">
<h2 id="dash-trainings-title" className="dashboard-section__title">
Trainings
</h2>
<p className="dashboard-section__description">
Einheiten, bei denen du als Leitung oder Co-Trainer eingetragen bist.
</p>
</div>
<div className="dashboard-section__actions">
<Link to="/planning" className="btn btn-secondary" style={{ textDecoration: 'none' }}>
<CalendarCheck size={16} strokeWidth={2} aria-hidden style={{ marginRight: 6 }} />
Planung
</Link>
</div>
</div>
<div className="dashboard-training-preview-grid">
<div className="card dashboard-preview-card">
<h3 className="dashboard-preview-card__title">Nächste Termine</h3>
{trainingHomeErr ? ( {trainingHomeErr ? (
<p style={{ color: 'var(--danger)', fontSize: '0.9rem' }}>{trainingHomeErr}</p> <p className="dashboard-preview-card__err">{trainingHomeErr}</p>
) : trainingHome?.upcoming?.length ? ( ) : trainingHome?.upcoming?.length ? (
<ul style={{ margin: 0, paddingLeft: '1.15rem', color: 'var(--text2)', fontSize: '0.9rem', lineHeight: 1.55 }}> <ul className="dashboard-preview-card__list">
{trainingHome.upcoming.map((u) => ( {trainingHome.upcoming.map((u) => (
<li key={u.id} style={{ marginBottom: '0.35rem' }}> <li key={u.id}>
<Link to={`/planning/run/${u.id}`} style={{ fontWeight: 600, color: 'var(--accent-dark)' }}> <Link to={`/planning/run/${u.id}`} className="dashboard-preview-card__link">
{unitWhenLabel(u)} {unitWhenLabel(u)}
</Link> </Link>
{u.group_name ? ( {u.group_name ? (
<span style={{ color: 'var(--text3)' }}>{`${u.group_name}`}</span> <span className="dashboard-preview-card__meta">{`${u.group_name}`}</span>
) : null} ) : null}
{u.lead_trainer_name ? ( {u.lead_trainer_name ? (
<span style={{ display: 'block', fontSize: '0.82rem', color: 'var(--text3)', marginTop: '2px' }}> <span className="dashboard-preview-card__sub">
Leitung: {u.lead_trainer_name} Leitung: {u.lead_trainer_name}
</span> </span>
) : null} ) : null}
@ -153,32 +303,30 @@ function Dashboard() {
))} ))}
</ul> </ul>
) : ( ) : (
<p style={{ color: 'var(--text2)', fontSize: '0.9rem', margin: 0 }}> <p className="dashboard-preview-card__empty">
Keine anstehenden Termine, bei denen du als Leitung oder Co-Trainer dieser Einheit eingetragen Keine anstehenden Termine.{' '}
bist. Unter{' '} <Link to="/planning">Zur Trainingsplanung</Link>
<Link to="/planning" style={{ color: 'var(--accent-dark)' }}>
Trainingsplanung
</Link>{' '}
kannst du Zeiträume und Zuordnungen bearbeiten.
</p> </p>
)} )}
</div> </div>
<div className="card"> <div className="card dashboard-preview-card">
<h3 style={{ fontSize: '1.05rem', marginBottom: '0.65rem' }}>Vermerk / Hinweise (anstehend)</h3> <h3 className="dashboard-preview-card__title">Hinweise (anstehend)</h3>
{trainingHomeErr ? ( {trainingHomeErr ? (
<p style={{ color: 'var(--danger)', fontSize: '0.9rem' }}>{trainingHomeErr}</p> <p className="dashboard-preview-card__err">{trainingHomeErr}</p>
) : trainingHome?.plannedWithNotes?.length ? ( ) : trainingHome?.plannedWithNotes?.length ? (
<ul style={{ margin: 0, paddingLeft: '1.15rem', color: 'var(--text2)', fontSize: '0.88rem', lineHeight: 1.5 }}> <ul className="dashboard-preview-card__list dashboard-preview-card__list--notes">
{trainingHome.plannedWithNotes.map((u) => { {trainingHome.plannedWithNotes.map((u) => {
const snippet = (u.trainer_notes || u.notes || '').trim().slice(0, 120) const snippet = (u.trainer_notes || u.notes || '').trim().slice(0, 120)
return ( return (
<li key={`n-${u.id}`} style={{ marginBottom: '0.5rem' }}> <li key={`n-${u.id}`}>
<Link to={`/planning/run/${u.id}`} style={{ fontWeight: 600, color: 'var(--accent-dark)' }}> <Link to={`/planning/run/${u.id}`} className="dashboard-preview-card__link">
{unitWhenLabel(u)} {unitWhenLabel(u)}
</Link> </Link>
{u.group_name ? <span style={{ color: 'var(--text3)' }}>{` · ${u.group_name}`}</span> : null} {u.group_name ? (
<div style={{ color: 'var(--text2)', marginTop: '4px' }}> <span className="dashboard-preview-card__meta">{` · ${u.group_name}`}</span>
) : null}
<div className="dashboard-preview-card__note-snippet">
{snippet} {snippet}
{(u.trainer_notes || u.notes || '').trim().length > 120 ? '…' : ''} {(u.trainer_notes || u.notes || '').trim().length > 120 ? '…' : ''}
</div> </div>
@ -187,68 +335,41 @@ function Dashboard() {
})} })}
</ul> </ul>
) : ( ) : (
<p style={{ color: 'var(--text2)', fontSize: '0.9rem', margin: 0 }}> <p className="dashboard-preview-card__empty">
Keine Einträge mit Allgemein oder TrainerNotizen in deinen nächsten geplanten Terminen. Keine Vermerke in den nächsten geplanten Terminen.
</p> </p>
)} )}
</div> </div>
<div className="card"> <div className="card dashboard-preview-card">
<h3 style={{ fontSize: '1.05rem', marginBottom: '0.65rem' }}>Rückschau (durchgeführt)</h3> <h3 className="dashboard-preview-card__title">Offene Rückschau</h3>
{trainingHomeErr ? ( {trainingHomeErr ? (
<p style={{ color: 'var(--danger)', fontSize: '0.9rem' }}>{trainingHomeErr}</p> <p className="dashboard-preview-card__err">{trainingHomeErr}</p>
) : trainingHome?.recent?.length ? ( ) : trainingHome?.reviewPending?.length ? (
<ul style={{ margin: 0, paddingLeft: '1.15rem', color: 'var(--text2)', fontSize: '0.9rem', lineHeight: 1.55 }}> <ul className="dashboard-preview-card__list">
{trainingHome.recent.map((u) => ( {trainingHome.reviewPending.map((u) => (
<li key={`r-${u.id}`} style={{ marginBottom: '0.35rem' }}> <li key={`r-${u.id}`}>
<Link to={`/planning/run/${u.id}`} style={{ fontWeight: 600, color: 'var(--accent-dark)' }}> <Link to={`/planning?unit=${u.id}`} className="dashboard-preview-card__link">
{(u.actual_date || u.planned_date || '').toString().slice(0, 10) || 'Datum'} {(u.actual_date || u.planned_date || '').toString().slice(0, 10) || 'Datum'}
</Link> </Link>
{u.group_name ? ( {u.group_name ? (
<span style={{ color: 'var(--text3)' }}>{`${u.group_name}`}</span> <span className="dashboard-preview-card__meta">{`${u.group_name}`}</span>
) : null} ) : null}
</li> </li>
))} ))}
</ul> </ul>
) : ( ) : (
<p style={{ color: 'var(--text2)', fontSize: '0.9rem', margin: 0 }}>Noch keine abgeschlossenen Einheiten in der Kurzliste.</p> <p className="dashboard-preview-card__empty">
Keine durchgeführten Trainings mit offener Nachbereitung. Zum Abschluss der Rückschau in der
Planung Rückschau erledigt aktivieren.
</p>
)} )}
</div> </div>
</div> </div>
)} <DashboardTrainingVisibilityWidget user={user} />
</section>
{version && ( </>
<div className="card"> ) : null}
<h3>System-Information</h3>
<div style={{ display: 'grid', gridTemplateColumns: '150px 1fr', gap: '0.5rem', marginTop: '1rem' }}>
<strong>Version:</strong>
<span>{version.app_version}</span>
<strong>Build:</strong>
<span>{version.build_date}</span>
<strong>Umgebung:</strong>
<span>{version.environment}</span>
<strong>DB Schema:</strong>
<span>{version.db_schema_version}</span>
<strong>Dein Tier:</strong>
<span style={{
padding: '0.25rem 0.5rem',
background: profile?.tier === 'premium' ? 'var(--accent)' : 'var(--surface2)',
color: profile?.tier === 'premium' ? 'white' : 'var(--text1)',
borderRadius: '4px',
display: 'inline-block'
}}>
{profile?.tier || 'free'}
</span>
<strong>Rolle:</strong>
<span>{profile?.role || 'user'}</span>
</div>
</div>
)}
</div> </div>
) )
} }

View File

@ -123,10 +123,45 @@ function levelOptionShort(levelStr) {
return o ? String(o.level) : String(levelStr) return o ? String(o.level) : String(levelStr)
} }
function applyDashboardExerciseListUrl(mergedFromPrefs) {
try {
const sp = new URLSearchParams(window.location.search)
const mine = sp.get('mine') === '1' || sp.get('created_by_me') === '1'
const statusDraft = sp.get('status') === 'draft'
if (mine) {
const next = { ...INITIAL_EXERCISE_LIST_FILTERS }
if (statusDraft) {
next.status_rules = [{ key: 'url-dashboard-draft', id: 'draft', mode: 'require' }]
}
return next
}
if (statusDraft) {
return {
...mergedFromPrefs,
status_rules: [{ key: 'url-dashboard-draft', id: 'draft', mode: 'require' }],
}
}
return mergedFromPrefs
} catch {
return mergedFromPrefs
}
}
function ExercisesListPage() { function ExercisesListPage() {
const { user, checkAuth } = useAuth() const { user, checkAuth } = useAuth()
const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin' const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
const [mineOnly, setMineOnly] = useState(() => {
try {
const sp = new URLSearchParams(window.location.search)
return sp.get('mine') === '1' || sp.get('created_by_me') === '1'
} catch {
return false
}
})
const [exercises, setExercises] = useState([]) const [exercises, setExercises] = useState([])
const [catalogs, setCatalogs] = useState({ const [catalogs, setCatalogs] = useState({
focusAreas: [], focusAreas: [],
@ -169,7 +204,14 @@ function ExercisesListPage() {
useEffect(() => { useEffect(() => {
if (!user?.id) return if (!user?.id) return
if (prefsAppliedRef.current) return if (prefsAppliedRef.current) return
setFilters(mergeExerciseListPrefsFromApi(user.exercise_list_prefs)) const merged = mergeExerciseListPrefsFromApi(user.exercise_list_prefs)
setFilters(applyDashboardExerciseListUrl(merged))
try {
const sp = new URLSearchParams(window.location.search)
if (sp.get('mine') === '1' || sp.get('created_by_me') === '1') setMineOnly(true)
} catch {
/* ignore */
}
prefsAppliedRef.current = true prefsAppliedRef.current = true
}, [user?.id, user?.exercise_list_prefs]) }, [user?.id, user?.exercise_list_prefs])
@ -241,6 +283,14 @@ function ExercisesListPage() {
const filterChips = useMemo(() => { const filterChips = useMemo(() => {
const chips = [] const chips = []
if (mineOnly) {
chips.push({
key: 'mine-only',
label: 'Nur von mir erstellt',
onRemove: () => setMineOnly(false),
})
}
pushCatalogRuleFilterChips(chips, 'focus_rules', filters.focus_rules, focusOptions, 'Fokus', setFilters) pushCatalogRuleFilterChips(chips, 'focus_rules', filters.focus_rules, focusOptions, 'Fokus', setFilters)
if (filters.focus_only_without) { if (filters.focus_only_without) {
@ -381,6 +431,7 @@ function ExercisesListPage() {
return chips return chips
}, [ }, [
mineOnly,
filters, filters,
focusOptions, focusOptions,
styleOptions, styleOptions,
@ -445,8 +496,9 @@ function ExercisesListPage() {
if (filters.include_archived) q.include_archived = true if (filters.include_archived) q.include_archived = true
if (debouncedSearch) q.search = debouncedSearch if (debouncedSearch) q.search = debouncedSearch
if (debouncedAiSearch) q.ai_search = debouncedAiSearch if (debouncedAiSearch) q.ai_search = debouncedAiSearch
if (mineOnly) q.created_by_me = true
return q return q
}, [filters, debouncedSearch, debouncedAiSearch]) }, [filters, debouncedSearch, debouncedAiSearch, mineOnly])
useEffect(() => { useEffect(() => {
setSelectedIds(new Set()) setSelectedIds(new Set())
@ -595,7 +647,10 @@ function ExercisesListPage() {
} }
} }
const resetAllFilters = useCallback(() => setFilters({ ...INITIAL_EXERCISE_LIST_FILTERS }), []) const resetAllFilters = useCallback(() => {
setMineOnly(false)
setFilters({ ...INITIAL_EXERCISE_LIST_FILTERS })
}, [])
const handleSaveExerciseFilterPrefs = useCallback(async () => { const handleSaveExerciseFilterPrefs = useCallback(async () => {
const uid = user?.id const uid = user?.id
@ -803,7 +858,16 @@ function ExercisesListPage() {
list="exercise-search-titles" list="exercise-search-titles"
enterKeyHint="search" enterKeyHint="search"
/> />
<div className="exercise-search-bar__actions"> <div className="exercise-search-bar__actions exercise-search-bar__actions--split">
<div className="exercise-search-bar__actions-main">
<button
type="button"
className={'btn btn-secondary exercise-mine-toggle' + (mineOnly ? ' exercise-mine-toggle--active' : '')}
onClick={() => setMineOnly((v) => !v)}
title="Nur Übungen, die mit deinem Profil als Ersteller gespeichert sind"
>
Meine Übungen
</button>
<button type="button" className="btn btn-secondary exercise-filter-trigger" onClick={() => setFilterModalOpen(true)}> <button type="button" className="btn btn-secondary exercise-filter-trigger" onClick={() => setFilterModalOpen(true)}>
Filter Filter
{filterChips.length > 0 ? ( {filterChips.length > 0 ? (
@ -818,6 +882,7 @@ function ExercisesListPage() {
</button> </button>
) : null} ) : null}
</div> </div>
</div>
{filterChips.length > 0 ? ( {filterChips.length > 0 ? (
<div className="exercise-filter-chips-row" role="list" aria-label="Aktive Filter"> <div className="exercise-filter-chips-row" role="list" aria-label="Aktive Filter">
{filterChips.map((c) => ( {filterChips.map((c) => (

View File

@ -0,0 +1,100 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import api from '../utils/api'
/**
* Technische System- und Build-Infos (ehemals Dashboard) unter Einstellungen für Betrieb/Diagnose.
*/
function SettingsSystemInfoPage() {
const { user } = useAuth()
const [version, setVersion] = useState(null)
const [err, setErr] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
;(async () => {
setErr(null)
try {
const v = await api.getVersion()
if (!cancelled) setVersion(v)
} catch (e) {
if (!cancelled) setErr(e.message || String(e))
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [])
return (
<div className="page-padding app-page" style={{ padding: '1rem' }}>
<p style={{ marginBottom: '0.75rem' }}>
<Link to="/settings" style={{ fontSize: '0.9rem' }}>
Zurück zu Einstellungen
</Link>
</p>
<h1 style={{ marginBottom: '0.35rem', fontSize: '1.5rem' }}>Systeminformationen</h1>
<p
style={{
color: 'var(--text2)',
marginBottom: '1.25rem',
fontSize: '0.95rem',
lineHeight: 1.5,
maxWidth: '40rem',
}}
>
Build, Umgebung und Schema-Stand der App hilfreich für Support oder nach Deployments. Tarif und Rolle
beziehen sich auf dein Konto.
</p>
{loading ? (
<p className="muted" style={{ margin: 0 }}>
Version wird geladen
</p>
) : null}
{err ? (
<p role="alert" style={{ color: 'var(--danger)', marginBottom: '1rem' }}>
{err}
</p>
) : null}
{version ? (
<div className="card dashboard-sys-card">
<h2 className="dashboard-sys-card__title" style={{ marginTop: 0 }}>
System
</h2>
<div className="dashboard-sys-card__grid">
<strong>Version</strong>
<span>{version.app_version}</span>
<strong>Build</strong>
<span>{version.build_date}</span>
<strong>Umgebung</strong>
<span>{version.environment}</span>
<strong>DB Schema</strong>
<span>{version.db_schema_version}</span>
<strong>Dein Tier</strong>
<span>
<span
className={
user?.tier === 'premium'
? 'dashboard-sys-card__pill dashboard-sys-card__pill--accent'
: 'dashboard-sys-card__pill'
}
>
{user?.tier || 'free'}
</span>
</span>
<strong>Rolle</strong>
<span>{user?.role || 'user'}</span>
</div>
</div>
) : null}
</div>
)
}
export default SettingsSystemInfoPage

View File

@ -1086,6 +1086,7 @@ export default function TrainingFrameworkProgramEditPage() {
<ExercisePickerModal <ExercisePickerModal
open={sectionPickerCtx != null} open={sectionPickerCtx != null}
multiSelect multiSelect
enableQuickCreateDraft
onClose={() => setSectionPickerCtx(null)} onClose={() => setSectionPickerCtx(null)}
onSelectExercises={async (picked) => { onSelectExercises={async (picked) => {
if (!sectionPickerCtx || !picked?.length) return if (!sectionPickerCtx || !picked?.length) return

View File

@ -1,10 +1,11 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react' import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { Link } from 'react-router-dom' import { Link, useSearchParams } from 'react-router-dom'
import api from '../utils/api' import api from '../utils/api'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import ExercisePickerModal from '../components/ExercisePickerModal' import ExercisePickerModal from '../components/ExercisePickerModal'
import ExercisePeekModal from '../components/ExercisePeekModal' import ExercisePeekModal from '../components/ExercisePeekModal'
import TrainingUnitSectionsEditor from '../components/TrainingUnitSectionsEditor' import TrainingUnitSectionsEditor from '../components/TrainingUnitSectionsEditor'
import TrainingPlanExerciseVisibilityPanel from '../components/TrainingPlanExerciseVisibilityPanel'
import PageSectionNav from '../components/PageSectionNav' import PageSectionNav from '../components/PageSectionNav'
import { import {
defaultSection, defaultSection,
@ -112,6 +113,8 @@ function filterDirectoryExcludingLead(directory, excludeLeadPid) {
} }
function TrainingPlanningPage() { function TrainingPlanningPage() {
const { user } = useAuth() const { user } = useAuth()
const [searchParams, setSearchParams] = useSearchParams()
const unitDeepLinkHandledRef = useRef(null)
const [groups, setGroups] = useState([]) const [groups, setGroups] = useState([])
const [selectedGroupId, setSelectedGroupId] = useState('') const [selectedGroupId, setSelectedGroupId] = useState('')
const [units, setUnits] = useState([]) const [units, setUnits] = useState([])
@ -169,9 +172,26 @@ function TrainingPlanningPage() {
status: 'planned', status: 'planned',
notes: '', notes: '',
trainer_notes: '', trainer_notes: '',
debrief_completed: false,
sections: [defaultSection()], sections: [defaultSection()],
...sessionAssignDefaults() ...sessionAssignDefaults()
}) })
const planningFormRef = useRef(formData)
planningFormRef.current = formData
const planningModalClubId = useMemo(() => {
const gid = Number(formData.group_id)
if (!Number.isFinite(gid) || gid < 1) return null
const g = groups.find((x) => Number(x.id) === gid)
if (!g || g.club_id == null || g.club_id === '') return null
const c = Number(g.club_id)
return Number.isFinite(c) ? c : null
}, [groups, formData.group_id])
const refreshPlanningSectionMeta = useCallback(async () => {
const next = await enrichSectionsWithVariants(planningFormRef.current.sections)
setFormData((prev) => ({ ...prev, sections: next }))
}, [])
const loadPlanTemplates = useCallback(async () => { const loadPlanTemplates = useCallback(async () => {
try { try {
@ -482,6 +502,7 @@ function TrainingPlanningPage() {
status: 'planned', status: 'planned',
notes: '', notes: '',
trainer_notes: '', trainer_notes: '',
debrief_completed: false,
sections: [defaultSection('Hauptteil')], sections: [defaultSection('Hauptteil')],
...sessionAssignDefaults() ...sessionAssignDefaults()
}) })
@ -510,6 +531,7 @@ function TrainingPlanningPage() {
status: 'planned', status: 'planned',
notes: '', notes: '',
trainer_notes: '', trainer_notes: '',
debrief_completed: false,
sections: [defaultSection('Hauptteil')], sections: [defaultSection('Hauptteil')],
...sessionAssignDefaults() ...sessionAssignDefaults()
}) })
@ -537,7 +559,7 @@ function TrainingPlanningPage() {
} }
} }
const handleEdit = async (unit) => { const handleEdit = useCallback(async (unit) => {
try { try {
const fullUnit = await api.getTrainingUnit(unit.id) const fullUnit = await api.getTrainingUnit(unit.id)
setEditingUnit(fullUnit) setEditingUnit(fullUnit)
@ -557,6 +579,7 @@ function TrainingPlanningPage() {
status: fullUnit.status || 'planned', status: fullUnit.status || 'planned',
notes: fullUnit.notes || '', notes: fullUnit.notes || '',
trainer_notes: fullUnit.trainer_notes || '', trainer_notes: fullUnit.trainer_notes || '',
debrief_completed: Boolean(fullUnit.debrief_completed_at),
sections, sections,
lead_trainer_profile_id: lead_trainer_profile_id:
fullUnit.lead_trainer_profile_id != null && fullUnit.lead_trainer_profile_id !== '' fullUnit.lead_trainer_profile_id != null && fullUnit.lead_trainer_profile_id !== ''
@ -579,8 +602,46 @@ function TrainingPlanningPage() {
setShowModal(true) setShowModal(true)
} catch (err) { } catch (err) {
alert('Fehler beim Laden: ' + err.message) alert('Fehler beim Laden: ' + err.message)
throw err
} }
}, [])
useEffect(() => {
if (!user?.id || loading) return
const uid = searchParams.get('unit')
if (!uid) {
unitDeepLinkHandledRef.current = null
return
} }
if (unitDeepLinkHandledRef.current === uid) return
const idNum = parseInt(uid, 10)
if (!Number.isFinite(idNum)) return
unitDeepLinkHandledRef.current = uid
handleEdit({ id: idNum })
.then(() => {
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev)
next.delete('unit')
next.delete('debrief')
return next
},
{ replace: true }
)
})
.catch(() => {
unitDeepLinkHandledRef.current = null
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev)
next.delete('unit')
next.delete('debrief')
return next
},
{ replace: true }
)
})
}, [user?.id, loading, searchParams, handleEdit, setSearchParams])
const handleSaveAsTemplate = async () => { const handleSaveAsTemplate = async () => {
const name = window.prompt('Name für die neue Trainingsvorlage (nur Abschnitts-Gliederung):') const name = window.prompt('Name für die neue Trainingsvorlage (nur Abschnitts-Gliederung):')
@ -691,6 +752,10 @@ function TrainingPlanningPage() {
trainer_notes: formData.trainer_notes || null, trainer_notes: formData.trainer_notes || null,
sections: sectionsPayload sections: sectionsPayload
} }
if (editingUnit) {
payload.debrief_completed =
(formData.status || '') === 'completed' ? !!formData.debrief_completed : false
}
const leadStr = String(formData.lead_trainer_profile_id || '').trim() const leadStr = String(formData.lead_trainer_profile_id || '').trim()
if (leadStr) { if (leadStr) {
payload.lead_trainer_profile_id = parseInt(leadStr, 10) payload.lead_trainer_profile_id = parseInt(leadStr, 10)
@ -725,7 +790,13 @@ function TrainingPlanningPage() {
const updateFormField = (field, value) => { const updateFormField = (field, value) => {
setFormData((prev) => { setFormData((prev) => {
if (field !== 'lead_trainer_profile_id') return { ...prev, [field]: value } if (field !== 'lead_trainer_profile_id') {
const patch = { ...prev, [field]: value }
if (field === 'status' && value !== 'completed') {
patch.debrief_completed = false
}
return patch
}
const ts = typeof value === 'string' ? value.trim() : String(value ?? '').trim() const ts = typeof value === 'string' ? value.trim() : String(value ?? '').trim()
const strip = new Set() const strip = new Set()
if (ts !== '') { if (ts !== '') {
@ -2150,6 +2221,13 @@ function TrainingPlanningPage() {
) : null} ) : null}
</div> </div>
<TrainingPlanExerciseVisibilityPanel
sections={formData.sections}
targetClubId={planningModalClubId}
user={user}
onMetaRefresh={refreshPlanningSectionMeta}
/>
<div style={{ marginTop: '2rem' }}> <div style={{ marginTop: '2rem' }}>
{editingUnit ? ( {editingUnit ? (
<div style={{ marginBottom: '1rem' }}> <div style={{ marginBottom: '1rem' }}>
@ -2298,6 +2376,34 @@ function TrainingPlanningPage() {
<option value="cancelled">Abgesagt</option> <option value="cancelled">Abgesagt</option>
</select> </select>
</div> </div>
{formData.status === 'completed' ? (
<div className="form-row" style={{ marginTop: '0.75rem' }}>
<label
style={{
display: 'flex',
alignItems: 'flex-start',
gap: '10px',
cursor: 'pointer',
lineHeight: 1.45,
}}
>
<input
type="checkbox"
checked={!!formData.debrief_completed}
onChange={(e) => updateFormField('debrief_completed', e.target.checked)}
style={{ marginTop: '3px' }}
/>
<span>
<strong>Rückschau erledigt</strong>
<span className="muted" style={{ display: 'block', fontSize: '0.82rem', marginTop: '5px' }}>
Wenn angehakt, erscheint die Einheit nicht mehr unter Offene Rückschau auf dem
Dashboard (Nachbereitung gilt als abgeschlossen).
</span>
</span>
</label>
</div>
) : null}
</> </>
)} )}
@ -2339,6 +2445,7 @@ function TrainingPlanningPage() {
<ExercisePickerModal <ExercisePickerModal
open={exercisePickerOpen} open={exercisePickerOpen}
multiSelect multiSelect
enableQuickCreateDraft
onClose={() => { onClose={() => {
setExercisePickerOpen(false) setExercisePickerOpen(false)
setExercisePickerTarget(null) setExercisePickerTarget(null)

View File

@ -996,6 +996,7 @@ export async function listTrainingUnits(filters = {}) {
if (filters.start_date) q.set('start_date', filters.start_date) if (filters.start_date) q.set('start_date', filters.start_date)
if (filters.end_date) q.set('end_date', filters.end_date) if (filters.end_date) q.set('end_date', filters.end_date)
if (filters.status) q.set('status', filters.status) if (filters.status) q.set('status', filters.status)
if (filters.debrief_pending === true) q.set('debrief_pending', 'true')
if (filters.assigned_to_me === true) q.set('assigned_to_me', 'true') if (filters.assigned_to_me === true) q.set('assigned_to_me', 'true')
if (filters.sort) q.set('sort', String(filters.sort)) if (filters.sort) q.set('sort', String(filters.sort))
if (filters.limit != null && filters.limit !== '') q.set('limit', String(filters.limit)) if (filters.limit != null && filters.limit !== '') q.set('limit', String(filters.limit))
@ -1003,6 +1004,19 @@ export async function listTrainingUnits(filters = {}) {
return request(`/api/training-units${qs ? `?${qs}` : ''}`) return request(`/api/training-units${qs ? `?${qs}` : ''}`)
} }
/** Dashboard: Übungen in geplanten Einheiten, die für den Verein noch auf Sichtbarkeit „Verein“ gehören. */
export async function getTrainingExerciseClubVisibilityQueue(filters = {}) {
const q = new URLSearchParams()
if (filters.start_date) q.set('start_date', String(filters.start_date))
if (filters.end_date) q.set('end_date', String(filters.end_date))
if (filters.assigned_to_me === false) q.set('assigned_to_me', 'false')
if (filters.limit_units != null && filters.limit_units !== '') {
q.set('limit_units', String(filters.limit_units))
}
const qs = q.toString()
return request(`/api/training-units/exercises-club-visibility-queue${qs ? `?${qs}` : ''}`)
}
export async function getTrainingUnit(id) { export async function getTrainingUnit(id) {
return request(`/api/training-units/${id}`) return request(`/api/training-units/${id}`)
} }
@ -1191,6 +1205,7 @@ export const api = {
// Training Planning // Training Planning
listTrainingUnits, listTrainingUnits,
getTrainingExerciseClubVisibilityQueue,
getTrainingUnit, getTrainingUnit,
createTrainingUnit, createTrainingUnit,
updateTrainingUnit, updateTrainingUnit,

View File

@ -23,20 +23,48 @@ export async function hydrateExercisePlanningRow(exercise) {
let title = exercise?.title || '' let title = exercise?.title || ''
const id = exercise?.id const id = exercise?.id
if (!id) return null if (!id) return null
let meta = {}
if (!variants.length) { if (!variants.length) {
try { try {
const full = await api.getExercise(id) const full = await api.getExercise(id)
variants = Array.isArray(full?.variants) ? full.variants : [] variants = Array.isArray(full?.variants) ? full.variants : []
title = full?.title || title title = full?.title || title
meta = {
exercise_visibility: full?.visibility || 'private',
exercise_club_id: full?.club_id ?? null,
exercise_created_by: full?.created_by ?? null,
exercise_status: full?.status || 'draft',
}
} catch { } catch {
variants = [] variants = []
} }
} else {
meta = {
exercise_visibility: exercise?.visibility ?? null,
exercise_club_id: exercise?.club_id ?? null,
exercise_created_by: exercise?.created_by ?? null,
exercise_status: exercise?.status ?? null,
}
if (meta.exercise_visibility == null || meta.exercise_created_by == null) {
try {
const full = await api.getExercise(id)
if (meta.exercise_visibility == null) meta.exercise_visibility = full?.visibility || 'private'
if (meta.exercise_club_id == null) meta.exercise_club_id = full?.club_id ?? null
if (meta.exercise_created_by == null) meta.exercise_created_by = full?.created_by ?? null
if (meta.exercise_status == null) meta.exercise_status = full?.status || 'draft'
} catch {
/* keep partial meta */
}
}
meta.exercise_visibility = meta.exercise_visibility || 'private'
meta.exercise_status = meta.exercise_status || 'draft'
} }
const row = exerciseRow() const row = exerciseRow()
row.exercise_id = id row.exercise_id = id
row.exercise_variant_id = '' row.exercise_variant_id = ''
row.exercise_title = title row.exercise_title = title
row.variants = variants row.variants = variants
Object.assign(row, meta)
return row return row
} }
@ -119,9 +147,20 @@ export async function enrichSectionsWithVariants(sections) {
cache.set(id, { cache.set(id, {
title: ex.title || '', title: ex.title || '',
variants: Array.isArray(ex.variants) ? ex.variants : [], variants: Array.isArray(ex.variants) ? ex.variants : [],
visibility: ex.visibility || 'private',
club_id: ex.club_id ?? null,
created_by: ex.created_by ?? null,
status: ex.status || 'draft',
}) })
} catch { } catch {
cache.set(id, { title: '', variants: [] }) cache.set(id, {
title: '',
variants: [],
visibility: 'private',
club_id: null,
created_by: null,
status: 'draft',
})
} }
}) })
) )
@ -137,6 +176,10 @@ export async function enrichSectionsWithVariants(sections) {
exercise_title: it.exercise_title || c.title, exercise_title: it.exercise_title || c.title,
variants: variants:
Array.isArray(it.variants) && it.variants.length > 0 ? it.variants : c.variants, Array.isArray(it.variants) && it.variants.length > 0 ? it.variants : c.variants,
exercise_visibility: c.visibility,
exercise_club_id: c.club_id,
exercise_created_by: c.created_by,
exercise_status: c.status,
} }
}), }),
})) }))

View File

@ -43,10 +43,12 @@ test('2. Dashboard lädt ohne Fehler', async ({ page }) => {
// Warte bis Spinner verschwunden // Warte bis Spinner verschwunden
await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 }); await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 });
// Zwei verschiedene "Willkommen"-Texte im Dashboard → kein ambiguity locator('text=Willkommen') // Dashboard: h1 „Dashboard“ + Begrüßungstext (nicht mehr „Willkommen bei Shinkan“ als Überschrift)
await expect( const main = page.locator('.app-main');
page.getByRole('heading', { name: /Willkommen bei Shinkan/i }), await expect(main.getByRole('heading', { level: 1, name: 'Dashboard' })).toBeVisible({
).toBeVisible({ timeout: 5000 }); timeout: 5000,
});
await expect(main.getByText(/Shinkan unterstützt dich/i)).toBeVisible({ timeout: 5000 });
await page.screenshot({ path: 'screenshots/02-dashboard.png' }); await page.screenshot({ path: 'screenshots/02-dashboard.png' });
console.log('✓ Dashboard OK'); console.log('✓ Dashboard OK');