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 (
- 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.
{trainingHomeErr}
- ) : trainingHome?.upcoming?.length ? ( -
- 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 ? (
+ <>
+
+ Trainings dieses Kalenderjahres beziehen sich auf den geplanten Termin (nicht
+ zwingend Abschlussdatum). Zahlen können bei sehr vielen Einträgen mit „+“ enden.
+ {phase0Err}
+
+ Kurzüberblick
+
+
{trainingHomeErr}
- ) : trainingHome?.plannedWithNotes?.length ? ( -+ Einheiten, bei denen du als Leitung oder Co-Trainer eingetragen bist. +
+{trainingHomeErr}
+ ) : trainingHome?.upcoming?.length ? ( +- Keine Einträge mit Allgemein‑ oder Trainer‑Notizen in deinen nächsten geplanten Terminen. -
- )} + ))} + + ) : ( ++ Keine anstehenden Termine.{' '} + Zur Trainingsplanung +
+ )} +{trainingHomeErr}
+ ) : trainingHome?.plannedWithNotes?.length ? ( ++ Keine Vermerke in den nächsten geplanten Terminen. +
+ )} +{trainingHomeErr}
+ ) : trainingHome?.recent?.length ? ( +Noch keine Einträge in der Kurzliste.
+ )} +{trainingHomeErr}
- ) : trainingHome?.recent?.length ? ( -Noch keine abgeschlossenen Einheiten in der Kurzliste.
- )} -