From 3bf0af001ae0a2aa41d8b481b1aa23e87ae843cc Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 7 May 2026 08:33:50 +0200 Subject: [PATCH] feat: enhance dashboard and exercises list with new filtering and UI components - Added a new filter option in the list_exercises function to allow users to view exercises created by the current profile. - Introduced new CSS styles for the dashboard, including KPI tiles and training previews, improving layout and user experience. - Updated the Dashboard component to fetch and display statistics related to user-created exercises, enhancing visibility of personal metrics. - Enhanced the ExercisesListPage to support filtering based on user-specific exercise creation, improving data relevance for users. --- backend/routers/exercises.py | 8 + frontend/src/app.css | 193 ++++++++++++ frontend/src/pages/Dashboard.jsx | 377 +++++++++++++++-------- frontend/src/pages/ExercisesListPage.jsx | 34 +- 4 files changed, 481 insertions(+), 131 deletions(-) diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py index 8ef0bef..f062a20 100644 --- a/backend/routers/exercises.py +++ b/backend/routers/exercises.py @@ -1009,6 +1009,10 @@ def list_exercises( default=False, description="Archivierte einbeziehen; Standard false (außer Statusfilter enthält archived)", ), + created_by_me: bool = Query( + default=False, + description="Nur Übungen, die vom aktuellen Profil angelegt wurden (created_by = Profil)", + ), tenant: TenantContext = Depends(get_tenant_context), ): """ @@ -1036,6 +1040,10 @@ def list_exercises( where.append(vis_sql) params.extend(vis_params) + if created_by_me: + where.append("e.created_by = %s") + params.append(profile_id) + vis_list = _merge_str_any(visibility_any, visibility) if vis_list: ph = ",".join(["%s"] * len(vis_list)) diff --git a/frontend/src/app.css b/frontend/src/app.css index b9f372a..207e750 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -3595,6 +3595,199 @@ 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); + grid-column: 1 / -1; +} + +.dashboard-phase0-kpis__loading { + font-size: 0.9rem; + margin: 0; + grid-column: 1 / -1; +} + +.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; +} + +.dashboard-training-preview-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); + gap: 14px; + align-items: stretch; +} + +.dashboard-preview-card__title { + font-size: 1rem; + font-weight: 700; + margin: 0 0 12px; + padding-bottom: 10px; + border-bottom: 1px solid var(--border); + color: var(--text1); +} + +.dashboard-preview-card__list { + margin: 0; + padding-left: 1.15rem; + color: var(--text2); + font-size: 0.9rem; + line-height: 1.55; +} + +.dashboard-preview-card__list--notes { + font-size: 0.88rem; + line-height: 1.5; +} + +.dashboard-preview-card__list li { + margin-bottom: 0.45rem; +} + +.dashboard-preview-card__link { + font-weight: 600; + color: var(--accent-dark); + text-decoration: none; +} + +.dashboard-preview-card__link:hover { + text-decoration: underline; +} + +.dashboard-preview-card__meta { + color: var(--text3); +} + +.dashboard-preview-card__sub { + display: block; + font-size: 0.82rem; + color: var(--text3); + margin-top: 3px; +} + +.dashboard-preview-card__note-snippet { + margin-top: 5px; + color: var(--text2); +} + +.dashboard-preview-card__empty { + margin: 0; + font-size: 0.9rem; + color: var(--text2); +} + +.dashboard-preview-card__empty a { + color: var(--accent-dark); + font-weight: 600; +} + +.dashboard-preview-card__err { + margin: 0; + font-size: 0.9rem; + color: var(--danger); +} + +.dashboard-sys-card__title { + margin-bottom: 12px; + font-size: 1rem; +} + +.dashboard-sys-card__grid { + display: grid; + grid-template-columns: minmax(0, 120px) 1fr; + gap: 0.5rem 1rem; + align-items: center; + font-size: 0.9rem; +} + +.dashboard-sys-card__pill { + display: inline-block; + padding: 0.2rem 0.5rem; + background: var(--surface2); + color: var(--text1); + border-radius: 6px; + font-size: 0.85rem; +} + +.dashboard-sys-card__pill--accent { + background: var(--accent); + color: #fff; +} + /* --- Übungen: Rich-Text & Kacheln --- */ .rich-text-editor-wrap { border: 1px solid var(--border); diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index 86cb278..4e75cc2 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -1,5 +1,6 @@ import React, { useState, useEffect } from 'react' import { Link } from 'react-router-dom' +import { CalendarCheck, ClipboardList, FilePenLine, Library } from 'lucide-react' import { useAuth } from '../context/AuthContext' import api from '../utils/api' import EmailVerificationBanner from '../components/EmailVerificationBanner' @@ -11,12 +12,19 @@ function unitWhenLabel(u) { return bits.length ? bits.join(' · ') : 'Termin' } +function formatCappedCount(n, capped) { + if (capped && n >= 1) return `${n}+` + return String(n) +} + function Dashboard() { const [version, setVersion] = useState(null) const [profile, setProfile] = useState(null) const [loading, setLoading] = useState(true) const [trainingHome, setTrainingHome] = useState(null) const [trainingHomeErr, setTrainingHomeErr] = useState(null) + const [phase0Stats, setPhase0Stats] = useState(null) + const [phase0Err, setPhase0Err] = useState(null) const { user } = useAuth() useEffect(() => { @@ -40,21 +48,21 @@ function Dashboard() { status: 'planned', start_date: today, sort: 'asc', - limit: 8 + limit: 8, }), api.listTrainingUnits({ assigned_to_me: true, status: 'completed', sort: 'desc', - limit: 6 + limit: 6, }), api.listTrainingUnits({ assigned_to_me: true, status: 'planned', start_date: today, sort: 'asc', - limit: 40 - }) + limit: 40, + }), ]) const noteHits = (plannedPool || []).filter((u) => { const tn = (u.trainer_notes || '').trim() @@ -65,7 +73,7 @@ function Dashboard() { setTrainingHome({ upcoming: Array.isArray(upcomingRaw) ? upcomingRaw : [], recent: Array.isArray(recentRaw) ? recentRaw : [], - plannedWithNotes: noteHits + plannedWithNotes: noteHits, }) } } catch (e) { @@ -81,12 +89,58 @@ function Dashboard() { } }, [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) { + setPhase0Stats({ + year, + draftCount: Array.isArray(draftList) ? draftList.length : 0, + draftCapped: Array.isArray(draftList) && draftList.length >= 100, + 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 () => { try { - const [versionData, profileData] = await Promise.all([ - api.getVersion(), - api.getCurrentProfile() - ]) + const [versionData, profileData] = await Promise.all([api.getVersion(), api.getCurrentProfile()]) setVersion(versionData) setProfile(profileData) } catch (err) { @@ -105,6 +159,9 @@ function Dashboard() { ) } + const draftsHref = '/exercises?status=draft&mine=1' + const mineHref = '/exercises?mine=1' + return (
@@ -113,142 +170,204 @@ function Dashboard() { Dashboard

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

{profile && } - {user?.id && ( -
-
-

Deine nächsten Trainings

- {trainingHomeErr ? ( -

{trainingHomeErr}

- ) : trainingHome?.upcoming?.length ? ( -
    - {trainingHome.upcoming.map((u) => ( -
  • - - {unitWhenLabel(u)} - - {u.group_name ? ( - {` — ${u.group_name}`} - ) : null} - {u.lead_trainer_name ? ( - - Leitung: {u.lead_trainer_name} - - ) : null} -
  • - ))} -
- ) : ( -

- Keine anstehenden Termine, bei denen du als Leitung oder Co-Trainer dieser Einheit eingetragen - bist. Unter{' '} - - Trainingsplanung - {' '} - kannst du Zeiträume und Zuordnungen bearbeiten. + {user?.id ? ( + <> +

+
+
+

+ Kurzüberblick +

+

+ Trainings dieses Kalenderjahres beziehen sich auf den geplanten Termin (nicht + zwingend Abschlussdatum). Zahlen können bei sehr vielen Einträgen mit „+“ enden.

- )} +
+
+ {phase0Err ? ( +

+ {phase0Err} +

+ ) : null} + {!phase0Err && phase0Stats ? ( + <> + + + + + + {formatCappedCount(phase0Stats.draftCount, phase0Stats.draftCapped)} + + Übungs-Entwürfe + finalisieren + + + + + + + {formatCappedCount(phase0Stats.mineCount, phase0Stats.mineCapped)} + + Meine Übungen + alle Status + +
+ + + + + {formatCappedCount(phase0Stats.ytdCompletedCount, phase0Stats.ytdCapped)} + + Gehalten {phase0Stats.year} + abrechnungsnah +
+ + ) : !phase0Err ? ( +
Zahlen werden geladen…
+ ) : null} +
+
-
-

Vermerk / Hinweise (anstehend)

- {trainingHomeErr ? ( -

{trainingHomeErr}

- ) : trainingHome?.plannedWithNotes?.length ? ( -
    - {trainingHome.plannedWithNotes.map((u) => { - const snippet = (u.trainer_notes || u.notes || '').trim().slice(0, 120) - return ( -
  • - +
    +
    +
    +

    + Trainings +

    +

    + Einheiten, bei denen du als Leitung oder Co-Trainer eingetragen bist. +

    +
    +
    + + + Planung + +
    +
    +
    +
    +

    Nächste Termine

    + {trainingHomeErr ? ( +

    {trainingHomeErr}

    + ) : trainingHome?.upcoming?.length ? ( +
      + {trainingHome.upcoming.map((u) => ( +
    • + {unitWhenLabel(u)} - {u.group_name ? {` · ${u.group_name}`} : null} -
      - {snippet} - {(u.trainer_notes || u.notes || '').trim().length > 120 ? '…' : ''} -
      + {u.group_name ? ( + {` — ${u.group_name}`} + ) : null} + {u.lead_trainer_name ? ( + + Leitung: {u.lead_trainer_name} + + ) : null}
    • - ) - })} -
    - ) : ( -

    - Keine Einträge mit Allgemein‑ oder Trainer‑Notizen in deinen nächsten geplanten Terminen. -

    - )} + ))} +
+ ) : ( +

+ Keine anstehenden Termine.{' '} + Zur Trainingsplanung +

+ )} +
+ +
+

Hinweise (anstehend)

+ {trainingHomeErr ? ( +

{trainingHomeErr}

+ ) : trainingHome?.plannedWithNotes?.length ? ( +
    + {trainingHome.plannedWithNotes.map((u) => { + const snippet = (u.trainer_notes || u.notes || '').trim().slice(0, 120) + return ( +
  • + + {unitWhenLabel(u)} + + {u.group_name ? ( + {` · ${u.group_name}`} + ) : null} +
    + {snippet} + {(u.trainer_notes || u.notes || '').trim().length > 120 ? '…' : ''} +
    +
  • + ) + })} +
+ ) : ( +

+ Keine Vermerke in den nächsten geplanten Terminen. +

+ )} +
+ +
+

Rückschau

+ {trainingHomeErr ? ( +

{trainingHomeErr}

+ ) : trainingHome?.recent?.length ? ( +
    + {trainingHome.recent.map((u) => ( +
  • + + {(u.actual_date || u.planned_date || '').toString().slice(0, 10) || 'Datum'} + + {u.group_name ? ( + {` — ${u.group_name}`} + ) : null} +
  • + ))} +
+ ) : ( +

Noch keine Einträge in der Kurzliste.

+ )} +
+ + + ) : null} -
-

Rückschau (durchgeführt)

- {trainingHomeErr ? ( -

{trainingHomeErr}

- ) : trainingHome?.recent?.length ? ( -
    - {trainingHome.recent.map((u) => ( -
  • - - {(u.actual_date || u.planned_date || '').toString().slice(0, 10) || 'Datum'} - - {u.group_name ? ( - {` — ${u.group_name}`} - ) : null} -
  • - ))} -
- ) : ( -

Noch keine abgeschlossenen Einheiten in der Kurzliste.

- )} -
-
- )} - - {version && ( -
-

System-Information

-
- Version: - {version.app_version} - - Build: - {version.build_date} - - Umgebung: - {version.environment} - - DB Schema: - {version.db_schema_version} - - Dein Tier: - + {version ? ( +
+

System

+
+ Version + {version.app_version} + Build + {version.build_date} + Umgebung + {version.environment} + DB Schema + {version.db_schema_version} + Dein Tier + + {profile?.tier || 'free'} - - Rolle: - {profile?.role || 'user'} -
+ + Rolle + {profile?.role || 'user'}
- )} +
+ ) : null}
) } diff --git a/frontend/src/pages/ExercisesListPage.jsx b/frontend/src/pages/ExercisesListPage.jsx index 5bc40ca..2695e8d 100644 --- a/frontend/src/pages/ExercisesListPage.jsx +++ b/frontend/src/pages/ExercisesListPage.jsx @@ -123,10 +123,32 @@ function levelOptionShort(levelStr) { return o ? String(o.level) : String(levelStr) } +function exerciseListFiltersWithDashboardUrl(mergedFilters) { + try { + const sp = new URLSearchParams(window.location.search) + if (sp.get('status') !== 'draft') return mergedFilters + return { + ...mergedFilters, + status_rules: [{ key: 'url-dashboard-draft', id: 'draft', mode: 'require' }], + } + } catch { + return mergedFilters + } +} + function ExercisesListPage() { const { user, checkAuth } = useAuth() 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 [catalogs, setCatalogs] = useState({ focusAreas: [], @@ -169,7 +191,14 @@ function ExercisesListPage() { useEffect(() => { if (!user?.id) return if (prefsAppliedRef.current) return - setFilters(mergeExerciseListPrefsFromApi(user.exercise_list_prefs)) + const merged = mergeExerciseListPrefsFromApi(user.exercise_list_prefs) + setFilters(exerciseListFiltersWithDashboardUrl(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 }, [user?.id, user?.exercise_list_prefs]) @@ -445,8 +474,9 @@ function ExercisesListPage() { if (filters.include_archived) q.include_archived = true if (debouncedSearch) q.search = debouncedSearch if (debouncedAiSearch) q.ai_search = debouncedAiSearch + if (mineOnly) q.created_by_me = true return q - }, [filters, debouncedSearch, debouncedAiSearch]) + }, [filters, debouncedSearch, debouncedAiSearch, mineOnly]) useEffect(() => { setSelectedIds(new Set())