shinkan-jinkendo/frontend/src/pages/Dashboard.jsx
Lars 58a38702b9
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
feat(org-inbox): implement join request inbox for platform and club admins
- 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.
2026-05-09 09:13:38 +02:00

380 lines
15 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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