From 3210796139a79b3db450379a0567cf49e0d12234 Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 7 May 2026 07:51:24 +0200 Subject: [PATCH 1/8] feat: update dashboard smoke test for new UI elements - Modified the dashboard smoke test to reflect changes in the main heading and greeting text. - Updated expectations to check for the visibility of the new "Dashboard" heading and the greeting message, enhancing test accuracy. --- tests/dev-smoke-test.spec.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/dev-smoke-test.spec.js b/tests/dev-smoke-test.spec.js index 8a9eb0d..39102a0 100644 --- a/tests/dev-smoke-test.spec.js +++ b/tests/dev-smoke-test.spec.js @@ -43,10 +43,12 @@ test('2. Dashboard lädt ohne Fehler', async ({ page }) => { // Warte bis Spinner verschwunden await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 }); - // Zwei verschiedene "Willkommen"-Texte im Dashboard → kein ambiguity locator('text=Willkommen') - await expect( - page.getByRole('heading', { name: /Willkommen bei Shinkan/i }), - ).toBeVisible({ timeout: 5000 }); + // Dashboard: h1 „Dashboard“ + Begrüßungstext (nicht mehr „Willkommen bei Shinkan“ als Überschrift) + const main = page.locator('.app-main'); + await expect(main.getByRole('heading', { level: 1, name: 'Dashboard' })).toBeVisible({ + timeout: 5000, + }); + await expect(main.getByText(/Shinkan unterstützt dich/i)).toBeVisible({ timeout: 5000 }); await page.screenshot({ path: 'screenshots/02-dashboard.png' }); console.log('✓ Dashboard OK'); -- 2.43.0 From 3bf0af001ae0a2aa41d8b481b1aa23e87ae843cc Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 7 May 2026 08:33:50 +0200 Subject: [PATCH 2/8] 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()) -- 2.43.0 From 40a3b4b8e60c57a818411dc5febcceb485667244 Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 7 May 2026 08:47:01 +0200 Subject: [PATCH 3/8] feat: enhance dashboard and exercises list UI with new styles and filtering options - Added responsive styles for dashboard KPI cards and exercise search actions, improving layout on smaller screens. - Refactored the Dashboard component to streamline error handling and loading states for better user experience. - Updated ExercisesListPage to include new filtering options for user-created exercises, enhancing data relevance. - Improved overall CSS organization and introduced new classes for better styling consistency across components. --- frontend/src/app.css | 68 ++++++++++++++++++- frontend/src/pages/Dashboard.jsx | 85 ++++++++++++------------ frontend/src/pages/ExercisesListPage.jsx | 77 +++++++++++++++------ 3 files changed, 163 insertions(+), 67 deletions(-) diff --git a/frontend/src/app.css b/frontend/src/app.css index 207e750..2969738 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -2706,6 +2706,21 @@ a.analysis-split__nav-item { gap: 10px; margin-top: 12px; } +.exercise-search-bar__actions--split { + width: 100%; +} +.exercise-search-bar__actions-main { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; +} +.exercise-mine-toggle--active { + border-color: var(--accent); + background: var(--accent-light); + color: var(--accent-dark); + font-weight: 600; +} .exercise-filter-trigger { display: inline-flex; align-items: center; @@ -3607,13 +3622,11 @@ a.analysis-split__nav-item { 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; + margin: 0 0 10px; } .dashboard-kpi-card { @@ -3685,6 +3698,55 @@ a.analysis-split__nav-item { 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)); diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index 4e75cc2..031231a 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -191,49 +191,48 @@ function Dashboard() {

-
- {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} -
+ {phase0Err ? ( +

+ {phase0Err} +

+ ) : null} + {!phase0Err && !phase0Stats ? ( +
Zahlen werden geladen…
+ ) : 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 +
+
+ ) : null}
diff --git a/frontend/src/pages/ExercisesListPage.jsx b/frontend/src/pages/ExercisesListPage.jsx index 2695e8d..2be9270 100644 --- a/frontend/src/pages/ExercisesListPage.jsx +++ b/frontend/src/pages/ExercisesListPage.jsx @@ -123,16 +123,29 @@ function levelOptionShort(levelStr) { return o ? String(o.level) : String(levelStr) } -function exerciseListFiltersWithDashboardUrl(mergedFilters) { +function applyDashboardExerciseListUrl(mergedFromPrefs) { 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' }], + 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 mergedFilters + return mergedFromPrefs } } @@ -192,7 +205,7 @@ function ExercisesListPage() { if (!user?.id) return if (prefsAppliedRef.current) return const merged = mergeExerciseListPrefsFromApi(user.exercise_list_prefs) - setFilters(exerciseListFiltersWithDashboardUrl(merged)) + setFilters(applyDashboardExerciseListUrl(merged)) try { const sp = new URLSearchParams(window.location.search) if (sp.get('mine') === '1' || sp.get('created_by_me') === '1') setMineOnly(true) @@ -270,6 +283,14 @@ function ExercisesListPage() { const filterChips = useMemo(() => { 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) if (filters.focus_only_without) { @@ -410,6 +431,7 @@ function ExercisesListPage() { return chips }, [ + mineOnly, filters, focusOptions, styleOptions, @@ -625,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 uid = user?.id @@ -833,20 +858,30 @@ function ExercisesListPage() { list="exercise-search-titles" enterKeyHint="search" /> -
- - {filterChips.length > 0 ? ( - - ) : null} + + {filterChips.length > 0 ? ( + + ) : null} +
{filterChips.length > 0 ? (
-- 2.43.0 From ff8fd78a310748ce73697caec9715003d984cfe3 Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 7 May 2026 09:01:01 +0200 Subject: [PATCH 4/8] feat: add debriefing functionality and enhance training unit filtering - Introduced a new filter option for listing training units to show only those with pending debriefs. - Updated the dashboard to reflect changes in training unit statuses, renaming components for clarity. - Enhanced the Training Planning Page to manage debrief completion status, including UI elements for user interaction. - Improved API utility to support new filtering criteria for training units, ensuring accurate data retrieval. --- .../044_training_unit_debrief_completed.sql | 10 ++ backend/routers/training_planning.py | 18 +++- frontend/src/pages/Dashboard.jsx | 21 +++-- frontend/src/pages/TrainingPlanningPage.jsx | 92 ++++++++++++++++++- frontend/src/utils/api.js | 1 + 5 files changed, 127 insertions(+), 15 deletions(-) create mode 100644 backend/migrations/044_training_unit_debrief_completed.sql diff --git a/backend/migrations/044_training_unit_debrief_completed.sql b/backend/migrations/044_training_unit_debrief_completed.sql new file mode 100644 index 0000000..b34459f --- /dev/null +++ b/backend/migrations/044_training_unit_debrief_completed.sql @@ -0,0 +1,10 @@ +-- Rückschau / Nachbereitung: explizit abschließbar (Dashboard & Filter) +ALTER TABLE training_units + ADD COLUMN IF NOT EXISTS debrief_completed_at TIMESTAMPTZ NULL; + +COMMENT ON COLUMN training_units.debrief_completed_at IS + 'Zeitpunkt, zu dem die Trainer-Rückschau (Nachbereitung) bewusst abgeschlossen wurde; NULL = offen'; + +CREATE INDEX IF NOT EXISTS idx_training_units_debrief_open + ON training_units (status, debrief_completed_at) + WHERE status = 'completed' AND debrief_completed_at IS NULL; diff --git a/backend/routers/training_planning.py b/backend/routers/training_planning.py index 1eddfea..c26c871 100644 --- a/backend/routers/training_planning.py +++ b/backend/routers/training_planning.py @@ -962,6 +962,10 @@ def list_training_units( end_date: Optional[str] = Query(default=None), status: Optional[str] = Query(default=None), assigned_to_me: bool = Query(default=False), + debrief_pending: bool = Query( + default=False, + description="Nur abgeschlossene Einheiten ohne gesetzte Rückschau (debrief_completed_at IS NULL)", + ), sort: str = Query(default="desc"), limit: Optional[int] = Query(default=None), tenant: TenantContext = Depends(get_tenant_context), @@ -1081,7 +1085,11 @@ def list_training_units( where.append("tu.planned_date <= %s") params.append(end_date) - if status: + if debrief_pending: + where.append("tu.status = %s") + params.append("completed") + where.append("tu.debrief_completed_at IS NULL") + elif status: where.append("tu.status = %s") params.append(status) @@ -1384,6 +1392,13 @@ def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depen assist_sql = ", assistant_trainer_profile_ids = %s" assist_params.append(na) + debrief_frag = "" + if "debrief_completed" in data and not is_blueprint: + if data.get("debrief_completed") is True: + debrief_frag = ", debrief_completed_at = NOW()" + else: + debrief_frag = ", debrief_completed_at = NULL" + cur.execute( f""" UPDATE training_units SET @@ -1402,6 +1417,7 @@ def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depen updated_at = NOW() {lead_sql} {assist_sql} + {debrief_frag} WHERE id = %s """, ( diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index 031231a..b393f43 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -42,7 +42,7 @@ function Dashboard() { setTrainingHomeErr(null) try { const today = new Date().toISOString().slice(0, 10) - const [upcomingRaw, recentRaw, plannedPool] = await Promise.all([ + const [upcomingRaw, reviewPendingRaw, plannedPool] = await Promise.all([ api.listTrainingUnits({ assigned_to_me: true, status: 'planned', @@ -52,9 +52,9 @@ function Dashboard() { }), api.listTrainingUnits({ assigned_to_me: true, - status: 'completed', + debrief_pending: true, sort: 'desc', - limit: 6, + limit: 8, }), api.listTrainingUnits({ assigned_to_me: true, @@ -72,7 +72,7 @@ function Dashboard() { if (!cancelled) { setTrainingHome({ upcoming: Array.isArray(upcomingRaw) ? upcomingRaw : [], - recent: Array.isArray(recentRaw) ? recentRaw : [], + reviewPending: Array.isArray(reviewPendingRaw) ? reviewPendingRaw : [], plannedWithNotes: noteHits, }) } @@ -315,14 +315,14 @@ function Dashboard() {
-

Rückschau

+

Offene Rückschau

{trainingHomeErr ? (

{trainingHomeErr}

- ) : trainingHome?.recent?.length ? ( + ) : trainingHome?.reviewPending?.length ? (
    - {trainingHome.recent.map((u) => ( + {trainingHome.reviewPending.map((u) => (
  • - + {(u.actual_date || u.planned_date || '').toString().slice(0, 10) || 'Datum'} {u.group_name ? ( @@ -332,7 +332,10 @@ function Dashboard() { ))}
) : ( -

Noch keine Einträge in der Kurzliste.

+

+ Keine durchgeführten Trainings mit offener Nachbereitung. Zum Abschluss der Rückschau in der + Planung „Rückschau erledigt“ aktivieren. +

)}
diff --git a/frontend/src/pages/TrainingPlanningPage.jsx b/frontend/src/pages/TrainingPlanningPage.jsx index fec3178..793b781 100644 --- a/frontend/src/pages/TrainingPlanningPage.jsx +++ b/frontend/src/pages/TrainingPlanningPage.jsx @@ -1,5 +1,5 @@ -import React, { useState, useEffect, useCallback, useMemo } from 'react' -import { Link } from 'react-router-dom' +import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react' +import { Link, useSearchParams } from 'react-router-dom' import api from '../utils/api' import { useAuth } from '../context/AuthContext' import ExercisePickerModal from '../components/ExercisePickerModal' @@ -112,6 +112,8 @@ function filterDirectoryExcludingLead(directory, excludeLeadPid) { } function TrainingPlanningPage() { const { user } = useAuth() + const [searchParams, setSearchParams] = useSearchParams() + const unitDeepLinkHandledRef = useRef(null) const [groups, setGroups] = useState([]) const [selectedGroupId, setSelectedGroupId] = useState('') const [units, setUnits] = useState([]) @@ -169,6 +171,7 @@ function TrainingPlanningPage() { status: 'planned', notes: '', trainer_notes: '', + debrief_completed: false, sections: [defaultSection()], ...sessionAssignDefaults() }) @@ -482,6 +485,7 @@ function TrainingPlanningPage() { status: 'planned', notes: '', trainer_notes: '', + debrief_completed: false, sections: [defaultSection('Hauptteil')], ...sessionAssignDefaults() }) @@ -510,6 +514,7 @@ function TrainingPlanningPage() { status: 'planned', notes: '', trainer_notes: '', + debrief_completed: false, sections: [defaultSection('Hauptteil')], ...sessionAssignDefaults() }) @@ -537,7 +542,7 @@ function TrainingPlanningPage() { } } - const handleEdit = async (unit) => { + const handleEdit = useCallback(async (unit) => { try { const fullUnit = await api.getTrainingUnit(unit.id) setEditingUnit(fullUnit) @@ -557,6 +562,7 @@ function TrainingPlanningPage() { status: fullUnit.status || 'planned', notes: fullUnit.notes || '', trainer_notes: fullUnit.trainer_notes || '', + debrief_completed: Boolean(fullUnit.debrief_completed_at), sections, lead_trainer_profile_id: fullUnit.lead_trainer_profile_id != null && fullUnit.lead_trainer_profile_id !== '' @@ -579,8 +585,46 @@ function TrainingPlanningPage() { setShowModal(true) } catch (err) { 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 name = window.prompt('Name für die neue Trainingsvorlage (nur Abschnitts-Gliederung):') @@ -691,6 +735,10 @@ function TrainingPlanningPage() { trainer_notes: formData.trainer_notes || null, sections: sectionsPayload } + if (editingUnit) { + payload.debrief_completed = + (formData.status || '') === 'completed' ? !!formData.debrief_completed : false + } const leadStr = String(formData.lead_trainer_profile_id || '').trim() if (leadStr) { payload.lead_trainer_profile_id = parseInt(leadStr, 10) @@ -725,7 +773,13 @@ function TrainingPlanningPage() { const updateFormField = (field, value) => { 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 strip = new Set() if (ts !== '') { @@ -2298,6 +2352,34 @@ function TrainingPlanningPage() { + + {formData.status === 'completed' ? ( +
+ +
+ ) : null} )} diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 447a7d1..b44b5b7 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -996,6 +996,7 @@ export async function listTrainingUnits(filters = {}) { if (filters.start_date) q.set('start_date', filters.start_date) if (filters.end_date) q.set('end_date', filters.end_date) 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.sort) q.set('sort', String(filters.sort)) if (filters.limit != null && filters.limit !== '') q.set('limit', String(filters.limit)) -- 2.43.0 From c6a7d668c5c9fafc97f22aff8ba22ef904d0f7d9 Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 7 May 2026 09:20:19 +0200 Subject: [PATCH 5/8] feat: add quick create draft functionality in ExercisePickerModal - Introduced a quick create draft feature allowing users to create private exercise drafts directly from the ExercisePickerModal. - Added state management for quick create inputs including title and summary, with validation for minimum title length. - Updated the Dashboard to display a preview of private exercise drafts, enhancing user visibility of pending exercises. - Enabled quick create functionality in TrainingFrameworkProgramEditPage and TrainingPlanningPage for streamlined exercise management. --- .../src/components/ExercisePickerModal.jsx | 124 ++++++++++++++++++ frontend/src/pages/Dashboard.jsx | 32 ++++- .../TrainingFrameworkProgramEditPage.jsx | 1 + frontend/src/pages/TrainingPlanningPage.jsx | 1 + 4 files changed, 156 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/ExercisePickerModal.jsx b/frontend/src/components/ExercisePickerModal.jsx index 658dc2f..bcf9e15 100644 --- a/frontend/src/components/ExercisePickerModal.jsx +++ b/frontend/src/components/ExercisePickerModal.jsx @@ -21,12 +21,17 @@ const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null) const INITIAL_FILTERS = { ...INITIAL_EXERCISE_LIST_FILTERS } +/** Stub-Ziel für API-Validator (mind. Ziel oder Durchführung); Nutzer ergänzt Details in der Übungsbearbeitung. */ +const QUICK_CREATE_GOAL_PLACEHOLDER = + 'Aus der Trainingsplanung angelegt — bitte Ziel und Durchführung in der Übungsbearbeitung ergänzen.' + export default function ExercisePickerModal({ open, onClose, onSelectExercise, multiSelect = false, onSelectExercises = null, + enableQuickCreateDraft = false, }) { const { user } = useAuth() const [catalogs, setCatalogs] = useState({ @@ -49,6 +54,10 @@ export default function ExercisePickerModal({ const [offset, setOffset] = useState(0) const [hasMore, setHasMore] = useState(false) const [multiPicked, setMultiPicked] = useState([]) + const [quickOpen, setQuickOpen] = useState(false) + const [quickTitle, setQuickTitle] = useState('') + const [quickSummary, setQuickSummary] = useState('') + const [quickSaving, setQuickSaving] = useState(false) const toggleMultiPick = (ex) => { setMultiPicked((prev) => @@ -110,6 +119,10 @@ export default function ExercisePickerModal({ setOffset(0) setHasMore(false) setMultiPicked([]) + setQuickOpen(false) + setQuickTitle('') + setQuickSummary('') + setQuickSaving(false) return } setFilters(mergeExerciseListPrefsFromApi(user?.exercise_list_prefs)) @@ -256,6 +269,48 @@ export default function ExercisePickerModal({ const resetFilters = () => setFilters({ ...INITIAL_FILTERS }) + const submitQuickCreate = async () => { + const title = (quickTitle || '').trim() + if (title.length < 3) { + alert('Titel: mindestens 3 Zeichen.') + return + } + const summaryRaw = (quickSummary || '').trim() + setQuickSaving(true) + try { + const created = await api.createExercise({ + title, + summary: summaryRaw || null, + goal: QUICK_CREATE_GOAL_PLACEHOLDER, + execution: null, + visibility: 'private', + status: 'draft', + equipment: [], + focus_areas_multi: [], + training_styles_multi: [], + training_types_multi: [], + target_groups_multi: [], + age_groups: [], + skills: [], + club_id: null, + }) + if (!created?.id) { + throw new Error('Anlegen fehlgeschlagen') + } + if (multiSelect && typeof onSelectExercises === 'function') { + await Promise.resolve(onSelectExercises([created])) + } else if (typeof onSelectExercise === 'function') { + await Promise.resolve(onSelectExercise(created)) + } + onClose() + } catch (e) { + console.error(e) + alert(e.message || 'Übung konnte nicht angelegt werden') + } finally { + setQuickSaving(false) + } + } + if (!open) return null return ( @@ -282,6 +337,75 @@ export default function ExercisePickerModal({ + {enableQuickCreateDraft ? ( +
+ + {quickOpen ? ( +
+

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

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