2026-04-21 14:56:16 +02:00
|
|
|
import React, { useState, useEffect } from 'react'
|
2026-05-05 15:40:29 +02:00
|
|
|
import { Link } from 'react-router-dom'
|
2026-05-07 08:33:50 +02:00
|
|
|
import { CalendarCheck, ClipboardList, FilePenLine, Library } from 'lucide-react'
|
2026-04-21 14:56:16 +02:00
|
|
|
import { useAuth } from '../context/AuthContext'
|
|
|
|
|
import api from '../utils/api'
|
2026-04-29 11:37:54 +02:00
|
|
|
import EmailVerificationBanner from '../components/EmailVerificationBanner'
|
2026-04-21 14:56:16 +02:00
|
|
|
|
2026-05-05 15:40:29 +02:00
|
|
|
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'
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-07 08:33:50 +02:00
|
|
|
function formatCappedCount(n, capped) {
|
|
|
|
|
if (capped && n >= 1) return `${n}+`
|
|
|
|
|
return String(n)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-21 14:56:16 +02:00
|
|
|
function Dashboard() {
|
|
|
|
|
const [version, setVersion] = useState(null)
|
|
|
|
|
const [profile, setProfile] = useState(null)
|
|
|
|
|
const [loading, setLoading] = useState(true)
|
2026-05-05 15:40:29 +02:00
|
|
|
const [trainingHome, setTrainingHome] = useState(null)
|
|
|
|
|
const [trainingHomeErr, setTrainingHomeErr] = useState(null)
|
2026-05-07 08:33:50 +02:00
|
|
|
const [phase0Stats, setPhase0Stats] = useState(null)
|
|
|
|
|
const [phase0Err, setPhase0Err] = useState(null)
|
2026-04-22 06:48:18 +02:00
|
|
|
const { user } = useAuth()
|
2026-04-21 14:56:16 +02:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
loadData()
|
|
|
|
|
}, [])
|
|
|
|
|
|
2026-05-05 15:40:29 +02:00
|
|
|
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)
|
2026-05-07 09:01:01 +02:00
|
|
|
const [upcomingRaw, reviewPendingRaw, plannedPool] = await Promise.all([
|
2026-05-05 15:40:29 +02:00
|
|
|
api.listTrainingUnits({
|
|
|
|
|
assigned_to_me: true,
|
|
|
|
|
status: 'planned',
|
|
|
|
|
start_date: today,
|
|
|
|
|
sort: 'asc',
|
2026-05-07 08:33:50 +02:00
|
|
|
limit: 8,
|
2026-05-05 15:40:29 +02:00
|
|
|
}),
|
|
|
|
|
api.listTrainingUnits({
|
|
|
|
|
assigned_to_me: true,
|
2026-05-07 09:01:01 +02:00
|
|
|
debrief_pending: true,
|
2026-05-05 15:40:29 +02:00
|
|
|
sort: 'desc',
|
2026-05-07 09:01:01 +02:00
|
|
|
limit: 8,
|
2026-05-05 15:40:29 +02:00
|
|
|
}),
|
|
|
|
|
api.listTrainingUnits({
|
|
|
|
|
assigned_to_me: true,
|
|
|
|
|
status: 'planned',
|
|
|
|
|
start_date: today,
|
|
|
|
|
sort: 'asc',
|
2026-05-07 08:33:50 +02:00
|
|
|
limit: 40,
|
|
|
|
|
}),
|
2026-05-05 15:40:29 +02:00
|
|
|
])
|
|
|
|
|
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 : [],
|
2026-05-07 09:01:01 +02:00
|
|
|
reviewPending: Array.isArray(reviewPendingRaw) ? reviewPendingRaw : [],
|
2026-05-07 08:33:50 +02:00
|
|
|
plannedWithNotes: noteHits,
|
2026-05-05 15:40:29 +02:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
if (!cancelled) {
|
|
|
|
|
console.error('Dashboard Trainingsübersicht:', e)
|
|
|
|
|
setTrainingHomeErr(e.message || 'Konnte Trainingsdaten nicht laden')
|
|
|
|
|
setTrainingHome(null)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})()
|
|
|
|
|
return () => {
|
|
|
|
|
cancelled = true
|
|
|
|
|
}
|
|
|
|
|
}, [user?.id])
|
|
|
|
|
|
2026-05-07 08:33:50 +02:00
|
|
|
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])
|
|
|
|
|
|
2026-04-21 14:56:16 +02:00
|
|
|
const loadData = async () => {
|
|
|
|
|
try {
|
2026-05-07 08:33:50 +02:00
|
|
|
const [versionData, profileData] = await Promise.all([api.getVersion(), api.getCurrentProfile()])
|
2026-04-21 14:56:16 +02:00
|
|
|
setVersion(versionData)
|
|
|
|
|
setProfile(profileData)
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('Failed to load data:', err)
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (loading) {
|
|
|
|
|
return (
|
2026-05-05 12:39:15 +02:00
|
|
|
<div className="app-page" style={{ padding: '2rem 0', textAlign: 'center' }}>
|
2026-04-21 14:56:16 +02:00
|
|
|
<div className="spinner"></div>
|
|
|
|
|
<p>Laden...</p>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-07 08:33:50 +02:00
|
|
|
const draftsHref = '/exercises?status=draft&mine=1'
|
|
|
|
|
const mineHref = '/exercises?mine=1'
|
|
|
|
|
|
2026-04-21 14:56:16 +02:00
|
|
|
return (
|
2026-05-06 10:37:01 +02:00
|
|
|
<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 }}>
|
2026-05-07 08:33:50 +02:00
|
|
|
Willkommen, {user?.name || user?.email}! Shinkan unterstützt dich bei Übungen, Planung und
|
|
|
|
|
Vereinsstruktur.
|
2026-04-21 14:56:16 +02:00
|
|
|
</p>
|
|
|
|
|
</div>
|
2026-05-06 10:37:01 +02:00
|
|
|
</div>
|
|
|
|
|
{profile && <EmailVerificationBanner profile={profile} />}
|
2026-04-21 14:56:16 +02:00
|
|
|
|
2026-05-07 08:33:50 +02:00
|
|
|
{user?.id ? (
|
|
|
|
|
<>
|
|
|
|
|
<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.
|
2026-05-05 15:40:29 +02:00
|
|
|
</p>
|
2026-05-07 08:33:50 +02:00
|
|
|
</div>
|
2026-05-05 15:40:29 +02:00
|
|
|
</div>
|
2026-05-07 08:47:01 +02:00
|
|
|
{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}
|
2026-05-07 08:33:50 +02:00
|
|
|
</section>
|
2026-05-05 15:40:29 +02:00
|
|
|
|
2026-05-07 08:33:50 +02:00
|
|
|
<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>
|
2026-05-05 15:40:29 +02:00
|
|
|
</div>
|
2026-05-07 08:33:50 +02:00
|
|
|
<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>
|
2026-04-21 14:56:16 +02:00
|
|
|
|
2026-05-07 08:33:50 +02:00
|
|
|
<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>
|
2026-04-21 14:56:16 +02:00
|
|
|
|
2026-05-07 08:33:50 +02:00
|
|
|
<div className="card dashboard-preview-card">
|
2026-05-07 09:01:01 +02:00
|
|
|
<h3 className="dashboard-preview-card__title">Offene Rückschau</h3>
|
2026-05-07 08:33:50 +02:00
|
|
|
{trainingHomeErr ? (
|
|
|
|
|
<p className="dashboard-preview-card__err">{trainingHomeErr}</p>
|
2026-05-07 09:01:01 +02:00
|
|
|
) : trainingHome?.reviewPending?.length ? (
|
2026-05-07 08:33:50 +02:00
|
|
|
<ul className="dashboard-preview-card__list">
|
2026-05-07 09:01:01 +02:00
|
|
|
{trainingHome.reviewPending.map((u) => (
|
2026-05-07 08:33:50 +02:00
|
|
|
<li key={`r-${u.id}`}>
|
2026-05-07 09:01:01 +02:00
|
|
|
<Link to={`/planning?unit=${u.id}`} className="dashboard-preview-card__link">
|
2026-05-07 08:33:50 +02:00
|
|
|
{(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>
|
|
|
|
|
) : (
|
2026-05-07 09:01:01 +02:00
|
|
|
<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>
|
2026-05-07 08:33:50 +02:00
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
</>
|
|
|
|
|
) : null}
|
2026-04-21 14:56:16 +02:00
|
|
|
|
2026-05-07 08:33:50 +02:00
|
|
|
{version ? (
|
|
|
|
|
<div className="card dashboard-sys-card">
|
|
|
|
|
<h3 className="dashboard-sys-card__title">System</h3>
|
|
|
|
|
<div className="dashboard-sys-card__grid">
|
|
|
|
|
<strong>Version</strong>
|
|
|
|
|
<span>{version.app_version}</span>
|
|
|
|
|
<strong>Build</strong>
|
|
|
|
|
<span>{version.build_date}</span>
|
|
|
|
|
<strong>Umgebung</strong>
|
|
|
|
|
<span>{version.environment}</span>
|
|
|
|
|
<strong>DB Schema</strong>
|
|
|
|
|
<span>{version.db_schema_version}</span>
|
|
|
|
|
<strong>Dein Tier</strong>
|
|
|
|
|
<span>
|
|
|
|
|
<span
|
|
|
|
|
className={
|
|
|
|
|
profile?.tier === 'premium' ? 'dashboard-sys-card__pill dashboard-sys-card__pill--accent' : 'dashboard-sys-card__pill'
|
|
|
|
|
}
|
|
|
|
|
>
|
2026-04-21 14:56:16 +02:00
|
|
|
{profile?.tier || 'free'}
|
|
|
|
|
</span>
|
2026-05-07 08:33:50 +02:00
|
|
|
</span>
|
|
|
|
|
<strong>Rolle</strong>
|
|
|
|
|
<span>{profile?.role || 'user'}</span>
|
2026-04-21 14:56:16 +02:00
|
|
|
</div>
|
2026-05-07 08:33:50 +02:00
|
|
|
</div>
|
|
|
|
|
) : null}
|
2026-04-21 14:56:16 +02:00
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default Dashboard
|