All checks were successful
Deploy Development / deploy (push) Successful in 41s
Test Suite / pytest-backend (push) Successful in 30s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Successful in 24s
- Added new API endpoint to retrieve join requests accessible by platform admins and club admins. - Implemented frontend components to display join requests in the inbox, including navigation updates and badge notifications. - Enhanced sidebar and navigation to conditionally show inbox based on user permissions. - Updated styles for inbox components and added responsive design for dashboard integration. - Introduced context management for inbox state and notifications on join request actions.
380 lines
15 KiB
JavaScript
380 lines
15 KiB
JavaScript
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'
|
||
import DashboardOrgInboxWidget from '../components/DashboardOrgInboxWidget'
|
||
|
||
function unitWhenLabel(u) {
|
||
const d = u.planned_date ? String(u.planned_date).slice(0, 10) : ''
|
||
const t = u.planned_time_start ? String(u.planned_time_start).slice(0, 5) : ''
|
||
const bits = [d, t].filter(Boolean)
|
||
return bits.length ? bits.join(' · ') : 'Termin'
|
||
}
|
||
|
||
function formatCappedCount(n, capped) {
|
||
if (capped && n >= 1) return `${n}+`
|
||
return String(n)
|
||
}
|
||
|
||
function Dashboard() {
|
||
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(() => {
|
||
loadData()
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
if (!user?.id) {
|
||
setTrainingHome(null)
|
||
setTrainingHomeErr(null)
|
||
return undefined
|
||
}
|
||
let cancelled = false
|
||
;(async () => {
|
||
setTrainingHomeErr(null)
|
||
try {
|
||
const today = new Date().toISOString().slice(0, 10)
|
||
const [upcomingRaw, reviewPendingRaw, plannedPool] = await Promise.all([
|
||
api.listTrainingUnits({
|
||
assigned_to_me: true,
|
||
status: 'planned',
|
||
start_date: today,
|
||
sort: 'asc',
|
||
limit: 8,
|
||
}),
|
||
api.listTrainingUnits({
|
||
assigned_to_me: true,
|
||
debrief_pending: true,
|
||
sort: 'desc',
|
||
limit: 8,
|
||
}),
|
||
api.listTrainingUnits({
|
||
assigned_to_me: true,
|
||
status: 'planned',
|
||
start_date: today,
|
||
sort: 'asc',
|
||
limit: 40,
|
||
}),
|
||
])
|
||
const noteHits = (plannedPool || []).filter((u) => {
|
||
const tn = (u.trainer_notes || '').trim()
|
||
const n = (u.notes || '').trim()
|
||
return Boolean(tn || n)
|
||
}).slice(0, 5)
|
||
if (!cancelled) {
|
||
setTrainingHome({
|
||
upcoming: Array.isArray(upcomingRaw) ? upcomingRaw : [],
|
||
reviewPending: Array.isArray(reviewPendingRaw) ? reviewPendingRaw : [],
|
||
plannedWithNotes: noteHits,
|
||
})
|
||
}
|
||
} catch (e) {
|
||
if (!cancelled) {
|
||
console.error('Dashboard Trainingsübersicht:', e)
|
||
setTrainingHomeErr(e.message || 'Konnte Trainingsdaten nicht laden')
|
||
setTrainingHome(null)
|
||
}
|
||
}
|
||
})()
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [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 profileData = await api.getCurrentProfile()
|
||
setProfile(profileData)
|
||
} catch (err) {
|
||
console.error('Failed to load data:', err)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="app-page" style={{ padding: '2rem 0', textAlign: 'center' }}>
|
||
<div className="spinner"></div>
|
||
<p>Laden...</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const draftsHref = '/exercises?status=draft&mine=1'
|
||
const mineHref = '/exercises?mine=1'
|
||
|
||
return (
|
||
<div className="app-page dashboard-page">
|
||
<div className="dashboard-greeting">
|
||
<div>
|
||
<h1 className="page-title" style={{ marginBottom: '6px' }}>
|
||
Dashboard
|
||
</h1>
|
||
<p className="muted" style={{ marginTop: 0 }}>
|
||
Willkommen, {user?.name || user?.email}! Shinkan unterstützt dich bei Übungen, Planung und
|
||
Vereinsstruktur.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
{profile && <EmailVerificationBanner profile={profile} />}
|
||
|
||
{user?.id ? (
|
||
<>
|
||
<DashboardOrgInboxWidget />
|
||
<section className="dashboard-section" aria-labelledby="dash-phase0-title">
|
||
<div className="dashboard-section__header">
|
||
<div className="dashboard-section__headline">
|
||
<h2 id="dash-phase0-title" className="dashboard-section__title">
|
||
Kurzüberblick
|
||
</h2>
|
||
<p className="dashboard-section__description">
|
||
Trainings dieses Kalenderjahres beziehen sich auf den <strong>geplanten Termin</strong> (nicht
|
||
zwingend Abschlussdatum). Zahlen können bei sehr vielen Einträgen mit „+“ enden.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
{phase0Err ? (
|
||
<p className="dashboard-phase0-kpis__err" role="alert">
|
||
{phase0Err}
|
||
</p>
|
||
) : null}
|
||
{!phase0Err && !phase0Stats ? (
|
||
<div className="dashboard-phase0-kpis__loading muted">Zahlen werden geladen…</div>
|
||
) : null}
|
||
{!phase0Err && phase0Stats ? (
|
||
<div className="dashboard-phase0-kpis">
|
||
<Link className="dashboard-kpi-card" to={draftsHref}>
|
||
<span className="dashboard-kpi-card__icon" aria-hidden>
|
||
<FilePenLine size={22} strokeWidth={2} />
|
||
</span>
|
||
<span className="dashboard-kpi-card__value">
|
||
{formatCappedCount(phase0Stats.draftCount, phase0Stats.draftCapped)}
|
||
</span>
|
||
<span className="dashboard-kpi-card__label">Übungs-Entwürfe</span>
|
||
<span className="dashboard-kpi-card__hint">finalisieren</span>
|
||
</Link>
|
||
<Link className="dashboard-kpi-card" to={mineHref}>
|
||
<span className="dashboard-kpi-card__icon" aria-hidden>
|
||
<Library size={22} strokeWidth={2} />
|
||
</span>
|
||
<span className="dashboard-kpi-card__value">
|
||
{formatCappedCount(phase0Stats.mineCount, phase0Stats.mineCapped)}
|
||
</span>
|
||
<span className="dashboard-kpi-card__label">Meine Übungen</span>
|
||
<span className="dashboard-kpi-card__hint">alle Status</span>
|
||
</Link>
|
||
<div className="dashboard-kpi-card dashboard-kpi-card--static">
|
||
<span className="dashboard-kpi-card__icon" aria-hidden>
|
||
<ClipboardList size={22} strokeWidth={2} />
|
||
</span>
|
||
<span className="dashboard-kpi-card__value">
|
||
{formatCappedCount(phase0Stats.ytdCompletedCount, phase0Stats.ytdCapped)}
|
||
</span>
|
||
<span className="dashboard-kpi-card__label">Gehalten {phase0Stats.year}</span>
|
||
<span className="dashboard-kpi-card__hint">abrechnungsnah</span>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
{!phase0Err && phase0Stats?.draftPreview?.length ? (
|
||
<div className="card dashboard-draft-preview" style={{ marginTop: '1rem' }}>
|
||
<h3 className="dashboard-preview-card__title" style={{ marginTop: 0 }}>
|
||
Entwürfe fertigstellen
|
||
</h3>
|
||
<p className="muted" style={{ marginTop: '0.35rem', marginBottom: '0.85rem', fontSize: '0.92rem' }}>
|
||
Private Übungs-Entwürfe (z. B. aus der Planung) — Ziel, Durchführung und Details in der Bearbeitung
|
||
ergänzen.
|
||
</p>
|
||
<ul className="dashboard-preview-card__list">
|
||
{phase0Stats.draftPreview.map((ex) => (
|
||
<li key={ex.id}>
|
||
<Link to={`/exercises/${ex.id}/edit`} className="dashboard-preview-card__link">
|
||
{ex.title}
|
||
</Link>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
<p style={{ margin: '0.75rem 0 0', fontSize: '0.86rem' }}>
|
||
<Link to={draftsHref}>Alle Entwürfe in der Übersicht</Link>
|
||
</p>
|
||
</div>
|
||
) : null}
|
||
</section>
|
||
|
||
<section className="dashboard-section" aria-labelledby="dash-trainings-title">
|
||
<div className="dashboard-section__header">
|
||
<div className="dashboard-section__headline">
|
||
<h2 id="dash-trainings-title" className="dashboard-section__title">
|
||
Trainings
|
||
</h2>
|
||
<p className="dashboard-section__description">
|
||
Einheiten, bei denen du als Leitung oder Co-Trainer eingetragen bist.
|
||
</p>
|
||
</div>
|
||
<div className="dashboard-section__actions">
|
||
<Link to="/planning" className="btn btn-secondary" style={{ textDecoration: 'none' }}>
|
||
<CalendarCheck size={16} strokeWidth={2} aria-hidden style={{ marginRight: 6 }} />
|
||
Planung
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
<div className="dashboard-training-preview-grid">
|
||
<div className="card dashboard-preview-card">
|
||
<h3 className="dashboard-preview-card__title">Nächste Termine</h3>
|
||
{trainingHomeErr ? (
|
||
<p className="dashboard-preview-card__err">{trainingHomeErr}</p>
|
||
) : trainingHome?.upcoming?.length ? (
|
||
<ul className="dashboard-preview-card__list">
|
||
{trainingHome.upcoming.map((u) => (
|
||
<li key={u.id}>
|
||
<Link to={`/planning/run/${u.id}`} className="dashboard-preview-card__link">
|
||
{unitWhenLabel(u)}
|
||
</Link>
|
||
{u.group_name ? (
|
||
<span className="dashboard-preview-card__meta">{` — ${u.group_name}`}</span>
|
||
) : null}
|
||
{u.lead_trainer_name ? (
|
||
<span className="dashboard-preview-card__sub">
|
||
Leitung: {u.lead_trainer_name}
|
||
</span>
|
||
) : null}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
) : (
|
||
<p className="dashboard-preview-card__empty">
|
||
Keine anstehenden Termine.{' '}
|
||
<Link to="/planning">Zur Trainingsplanung</Link>
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
<div className="card dashboard-preview-card">
|
||
<h3 className="dashboard-preview-card__title">Hinweise (anstehend)</h3>
|
||
{trainingHomeErr ? (
|
||
<p className="dashboard-preview-card__err">{trainingHomeErr}</p>
|
||
) : trainingHome?.plannedWithNotes?.length ? (
|
||
<ul className="dashboard-preview-card__list dashboard-preview-card__list--notes">
|
||
{trainingHome.plannedWithNotes.map((u) => {
|
||
const snippet = (u.trainer_notes || u.notes || '').trim().slice(0, 120)
|
||
return (
|
||
<li key={`n-${u.id}`}>
|
||
<Link to={`/planning/run/${u.id}`} className="dashboard-preview-card__link">
|
||
{unitWhenLabel(u)}
|
||
</Link>
|
||
{u.group_name ? (
|
||
<span className="dashboard-preview-card__meta">{` · ${u.group_name}`}</span>
|
||
) : null}
|
||
<div className="dashboard-preview-card__note-snippet">
|
||
{snippet}
|
||
{(u.trainer_notes || u.notes || '').trim().length > 120 ? '…' : ''}
|
||
</div>
|
||
</li>
|
||
)
|
||
})}
|
||
</ul>
|
||
) : (
|
||
<p className="dashboard-preview-card__empty">
|
||
Keine Vermerke in den nächsten geplanten Terminen.
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
<div className="card dashboard-preview-card">
|
||
<h3 className="dashboard-preview-card__title">Offene Rückschau</h3>
|
||
{trainingHomeErr ? (
|
||
<p className="dashboard-preview-card__err">{trainingHomeErr}</p>
|
||
) : trainingHome?.reviewPending?.length ? (
|
||
<ul className="dashboard-preview-card__list">
|
||
{trainingHome.reviewPending.map((u) => (
|
||
<li key={`r-${u.id}`}>
|
||
<Link to={`/planning?unit=${u.id}`} className="dashboard-preview-card__link">
|
||
{(u.actual_date || u.planned_date || '').toString().slice(0, 10) || 'Datum'}
|
||
</Link>
|
||
{u.group_name ? (
|
||
<span className="dashboard-preview-card__meta">{` — ${u.group_name}`}</span>
|
||
) : null}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
) : (
|
||
<p className="dashboard-preview-card__empty">
|
||
Keine durchgeführten Trainings mit offener Nachbereitung. Zum Abschluss der Rückschau in der
|
||
Planung „Rückschau erledigt“ aktivieren.
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<DashboardTrainingVisibilityWidget user={user} />
|
||
</section>
|
||
</>
|
||
) : null}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default Dashboard
|