Compare commits
9 Commits
d04ebee1f6
...
c8a08f8a94
| Author | SHA1 | Date | |
|---|---|---|---|
| c8a08f8a94 | |||
| 18fa4de055 | |||
| fe703ca414 | |||
| f9d518fb78 | |||
| c6a7d668c5 | |||
| ff8fd78a31 | |||
| 40a3b4b8e6 | |||
| 3bf0af001a | |||
| 3210796139 |
10
backend/migrations/044_training_unit_debrief_completed.sql
Normal file
10
backend/migrations/044_training_unit_debrief_completed.sql
Normal 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;
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 />} />
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
347
frontend/src/components/DashboardTrainingVisibilityWidget.jsx
Normal file
347
frontend/src/components/DashboardTrainingVisibilityWidget.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
280
frontend/src/components/TrainingPlanExerciseVisibilityPanel.jsx
Normal file
280
frontend/src/components/TrainingPlanExerciseVisibilityPanel.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 Trainer‑Notizen 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) => (
|
||||||
|
|
|
||||||
100
frontend/src/pages/SettingsSystemInfoPage.jsx
Normal file
100
frontend/src/pages/SettingsSystemInfoPage.jsx
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user