diff --git a/frontend/src/components/TrainingPlanExerciseVisibilityPanel.jsx b/frontend/src/components/TrainingPlanExerciseVisibilityPanel.jsx
new file mode 100644
index 0000000..a14990c
--- /dev/null
+++ b/frontend/src/components/TrainingPlanExerciseVisibilityPanel.jsx
@@ -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 (
+
+
Sichtbarkeit für den Verein
+
+ Übungen mit Sichtbarkeit „Privat“ oder einem anderen Verein sieht das Team bei der Durchführung
+ nicht. Hier können Sie sie auf Verein setzen (gleiche Logik wie beim Speichern der
+ Einheit).
+
+ {targetClubId == null || !Number.isFinite(Number(targetClubId)) ? (
+
+ Wählen Sie eine Trainingsgruppe, um passende Freigaben anzuzeigen.
+
+ ) : null}
+ {targetClubId != null && Number.isFinite(Number(targetClubId)) && !pending.length && rows.length ? (
+
+ Alle {rows.length} {rows.length === 1 ? 'Übung ist' : 'Übungen sind'} für diesen Verein in der
+ Durchführung sichtbar (oder offiziell).
+
+ ) : null}
+ {targetClubId != null && Number.isFinite(Number(targetClubId)) && pending.length ? (
+ <>
+
+
+ {bulkBusy ? 'Speichern…' : `Alle auf Verein (${promotableIds.length})`}
+
+ {okCount > 0 ? (
+
+ {okCount} weitere {okCount === 1 ? 'Übung' : 'Übungen'} bereits passend
+
+ ) : null}
+
+
+ {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 (
+
+
+
+ {r.title}
+
+
+ Aktuell: {visLabel}
+ {vis === 'club' && r.clubId != null && r.clubId !== Number(targetClubId)
+ ? ` · anderer Verein (#${r.clubId})`
+ : ''}
+
+
+ onPromoteOne(r.id)}
+ >
+ {loading ? '…' : 'Auf Verein'}
+
+
+ )
+ })}
+
+ {pending.some((r) => !userMayPromote(user, targetClubId, r.createdBy)) ? (
+
+ 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.
+
+ ) : null}
+ >
+ ) : null}
+ {message ? (
+
+ {message}
+
+ ) : null}
+
+ )
+}
diff --git a/frontend/src/pages/AccountSettingsPage.jsx b/frontend/src/pages/AccountSettingsPage.jsx
index ea6634c..28fdc64 100644
--- a/frontend/src/pages/AccountSettingsPage.jsx
+++ b/frontend/src/pages/AccountSettingsPage.jsx
@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react'
+import { Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import api from '../utils/api'
@@ -411,6 +412,11 @@ function AccountSettingsPage() {
+
+
+ Technische Systeminformationen
+ {' — App-Version, Build, Umgebung, Datenbankschema'}
+
)
}
diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx
index 86cb278..6dc8ee9 100644
--- a/frontend/src/pages/Dashboard.jsx
+++ b/frontend/src/pages/Dashboard.jsx
@@ -1,8 +1,10 @@
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'
+import DashboardTrainingVisibilityWidget from '../components/DashboardTrainingVisibilityWidget'
function unitWhenLabel(u) {
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'
}
+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(() => {
@@ -34,27 +42,27 @@ 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',
start_date: today,
sort: 'asc',
- limit: 8
+ limit: 8,
}),
api.listTrainingUnits({
assigned_to_me: true,
- status: 'completed',
+ debrief_pending: true,
sort: 'desc',
- limit: 6
+ limit: 8,
}),
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()
@@ -64,8 +72,8 @@ function Dashboard() {
if (!cancelled) {
setTrainingHome({
upcoming: Array.isArray(upcomingRaw) ? upcomingRaw : [],
- recent: Array.isArray(recentRaw) ? recentRaw : [],
- plannedWithNotes: noteHits
+ reviewPending: Array.isArray(reviewPendingRaw) ? reviewPendingRaw : [],
+ plannedWithNotes: noteHits,
})
}
} catch (e) {
@@ -81,13 +89,63 @@ 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) {
+ 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 () => {
try {
- const [versionData, profileData] = await Promise.all([
- api.getVersion(),
- api.getCurrentProfile()
- ])
- setVersion(versionData)
+ const profileData = await api.getCurrentProfile()
setProfile(profileData)
} catch (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 (
@@ -113,142 +174,202 @@ 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)}
+ {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 ? (
+ 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}
+ {!phase0Err && phase0Stats?.draftPreview?.length ? (
+
+
+ Entwürfe fertigstellen
+
+
+ Private Übungs-Entwürfe (z. B. aus der Planung) — Ziel, Durchführung und Details in der Bearbeitung
+ ergänzen.
+
+
+ {phase0Stats.draftPreview.map((ex) => (
+
+
+ {ex.title}
- {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.
+
+ Alle Entwürfe in der Übersicht
- )}
-
+
+ ) : 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 ? (
+
- ) : (
-
- 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 ? (
+
+ ) : (
+
+ Keine Vermerke in den nächsten geplanten Terminen.
+
+ )}
+
+
+
+
Offene Rückschau
+ {trainingHomeErr ? (
+
{trainingHomeErr}
+ ) : trainingHome?.reviewPending?.length ? (
+
+ {trainingHome.reviewPending.map((u) => (
+
+
+ {(u.actual_date || u.planned_date || '').toString().slice(0, 10) || 'Datum'}
+
+ {u.group_name ? (
+ {` — ${u.group_name}`}
+ ) : null}
+
+ ))}
+
+ ) : (
+
+ Keine durchgeführten Trainings mit offener Nachbereitung. Zum Abschluss der Rückschau in der
+ Planung „Rückschau erledigt“ aktivieren.
+
+ )}
+
-
-
-
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:
-
- {profile?.tier || 'free'}
-
-
- Rolle:
- {profile?.role || 'user'}
-
-
- )}
+
+
+ >
+ ) : null}
)
}
diff --git a/frontend/src/pages/ExercisesListPage.jsx b/frontend/src/pages/ExercisesListPage.jsx
index 5bc40ca..2be9270 100644
--- a/frontend/src/pages/ExercisesListPage.jsx
+++ b/frontend/src/pages/ExercisesListPage.jsx
@@ -123,10 +123,45 @@ function levelOptionShort(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() {
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 +204,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(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
}, [user?.id, user?.exercise_list_prefs])
@@ -241,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) {
@@ -381,6 +431,7 @@ function ExercisesListPage() {
return chips
}, [
+ mineOnly,
filters,
focusOptions,
styleOptions,
@@ -445,8 +496,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())
@@ -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 uid = user?.id
@@ -803,20 +858,30 @@ function ExercisesListPage() {
list="exercise-search-titles"
enterKeyHint="search"
/>
-
-
setFilterModalOpen(true)}>
- Filter
- {filterChips.length > 0 ? (
-
- {filterChips.length}
-
- ) : null}
-
- {filterChips.length > 0 ? (
-
- Alle entfernen
+
+
+ setMineOnly((v) => !v)}
+ title="Nur Übungen, die mit deinem Profil als Ersteller gespeichert sind"
+ >
+ Meine Übungen
- ) : null}
+ setFilterModalOpen(true)}>
+ Filter
+ {filterChips.length > 0 ? (
+
+ {filterChips.length}
+
+ ) : null}
+
+ {filterChips.length > 0 ? (
+
+ Alle entfernen
+
+ ) : null}
+
{filterChips.length > 0 ? (
diff --git a/frontend/src/pages/SettingsSystemInfoPage.jsx b/frontend/src/pages/SettingsSystemInfoPage.jsx
new file mode 100644
index 0000000..9e1fcc3
--- /dev/null
+++ b/frontend/src/pages/SettingsSystemInfoPage.jsx
@@ -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 (
+
+
+
+ ← Zurück zu Einstellungen
+
+
+
Systeminformationen
+
+ Build, Umgebung und Schema-Stand der App — hilfreich für Support oder nach Deployments. Tarif und Rolle
+ beziehen sich auf dein Konto.
+
+
+ {loading ? (
+
+ Version wird geladen…
+
+ ) : null}
+ {err ? (
+
+ {err}
+
+ ) : null}
+
+ {version ? (
+
+
+ System
+
+
+ Version
+ {version.app_version}
+ Build
+ {version.build_date}
+ Umgebung
+ {version.environment}
+ DB Schema
+ {version.db_schema_version}
+ Dein Tier
+
+
+ {user?.tier || 'free'}
+
+
+ Rolle
+ {user?.role || 'user'}
+
+
+ ) : null}
+
+ )
+}
+
+export default SettingsSystemInfoPage
diff --git a/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx b/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx
index 05879ff..011808b 100644
--- a/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx
+++ b/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx
@@ -1086,6 +1086,7 @@ export default function TrainingFrameworkProgramEditPage() {
setSectionPickerCtx(null)}
onSelectExercises={async (picked) => {
if (!sectionPickerCtx || !picked?.length) return
diff --git a/frontend/src/pages/TrainingPlanningPage.jsx b/frontend/src/pages/TrainingPlanningPage.jsx
index fec3178..e41289b 100644
--- a/frontend/src/pages/TrainingPlanningPage.jsx
+++ b/frontend/src/pages/TrainingPlanningPage.jsx
@@ -1,10 +1,11 @@
-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'
import ExercisePeekModal from '../components/ExercisePeekModal'
import TrainingUnitSectionsEditor from '../components/TrainingUnitSectionsEditor'
+import TrainingPlanExerciseVisibilityPanel from '../components/TrainingPlanExerciseVisibilityPanel'
import PageSectionNav from '../components/PageSectionNav'
import {
defaultSection,
@@ -112,6 +113,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,9 +172,26 @@ function TrainingPlanningPage() {
status: 'planned',
notes: '',
trainer_notes: '',
+ debrief_completed: false,
sections: [defaultSection()],
...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 () => {
try {
@@ -482,6 +502,7 @@ function TrainingPlanningPage() {
status: 'planned',
notes: '',
trainer_notes: '',
+ debrief_completed: false,
sections: [defaultSection('Hauptteil')],
...sessionAssignDefaults()
})
@@ -510,6 +531,7 @@ function TrainingPlanningPage() {
status: 'planned',
notes: '',
trainer_notes: '',
+ debrief_completed: false,
sections: [defaultSection('Hauptteil')],
...sessionAssignDefaults()
})
@@ -537,7 +559,7 @@ function TrainingPlanningPage() {
}
}
- const handleEdit = async (unit) => {
+ const handleEdit = useCallback(async (unit) => {
try {
const fullUnit = await api.getTrainingUnit(unit.id)
setEditingUnit(fullUnit)
@@ -557,6 +579,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 +602,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 +752,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 +790,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 !== '') {
@@ -2150,6 +2221,13 @@ function TrainingPlanningPage() {
) : null}
+
+
{editingUnit ? (
@@ -2298,6 +2376,34 @@ function TrainingPlanningPage() {
Abgesagt
+
+ {formData.status === 'completed' ? (
+
+
+ updateFormField('debrief_completed', e.target.checked)}
+ style={{ marginTop: '3px' }}
+ />
+
+ Rückschau erledigt
+
+ Wenn angehakt, erscheint die Einheit nicht mehr unter „Offene Rückschau“ auf dem
+ Dashboard (Nachbereitung gilt als abgeschlossen).
+
+
+
+
+ ) : null}
>
)}
@@ -2339,6 +2445,7 @@ function TrainingPlanningPage() {
{
setExercisePickerOpen(false)
setExercisePickerTarget(null)
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js
index 447a7d1..2f21734 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))
@@ -1003,6 +1004,19 @@ export async function listTrainingUnits(filters = {}) {
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) {
return request(`/api/training-units/${id}`)
}
@@ -1191,6 +1205,7 @@ export const api = {
// Training Planning
listTrainingUnits,
+ getTrainingExerciseClubVisibilityQueue,
getTrainingUnit,
createTrainingUnit,
updateTrainingUnit,
diff --git a/frontend/src/utils/trainingUnitSectionsForm.js b/frontend/src/utils/trainingUnitSectionsForm.js
index 01c9b4e..de9670e 100644
--- a/frontend/src/utils/trainingUnitSectionsForm.js
+++ b/frontend/src/utils/trainingUnitSectionsForm.js
@@ -23,20 +23,48 @@ export async function hydrateExercisePlanningRow(exercise) {
let title = exercise?.title || ''
const id = exercise?.id
if (!id) return null
+ let meta = {}
if (!variants.length) {
try {
const full = await api.getExercise(id)
variants = Array.isArray(full?.variants) ? full.variants : []
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 {
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()
row.exercise_id = id
row.exercise_variant_id = ''
row.exercise_title = title
row.variants = variants
+ Object.assign(row, meta)
return row
}
@@ -119,9 +147,20 @@ export async function enrichSectionsWithVariants(sections) {
cache.set(id, {
title: ex.title || '',
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 {
- 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,
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,
}
}),
}))
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');