2026-05-13 16:34:38 +02:00
|
|
|
|
import React, { useState, useEffect, useMemo } 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-05-13 16:34:38 +02:00
|
|
|
|
import { getTenantClubDependencyKey } from '../utils/activeClub'
|
2026-04-29 11:37:54 +02:00
|
|
|
|
import EmailVerificationBanner from '../components/EmailVerificationBanner'
|
2026-05-07 10:19:55 +02:00
|
|
|
|
import DashboardTrainingVisibilityWidget from '../components/DashboardTrainingVisibilityWidget'
|
2026-05-09 09:13:38 +02:00
|
|
|
|
import DashboardOrgInboxWidget from '../components/DashboardOrgInboxWidget'
|
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() {
|
2026-05-05 15:40:29 +02:00
|
|
|
|
const [trainingHome, setTrainingHome] = useState(null)
|
2026-05-07 08:33:50 +02:00
|
|
|
|
const [phase0Stats, setPhase0Stats] = useState(null)
|
2026-05-14 08:53:09 +02:00
|
|
|
|
const [dashboardKpisErr, setDashboardKpisErr] = useState(null)
|
2026-05-14 06:42:13 +02:00
|
|
|
|
const { user, loading: authLoading } = useAuth()
|
2026-05-13 16:34:38 +02:00
|
|
|
|
const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user])
|
2026-04-21 14:56:16 +02:00
|
|
|
|
|
2026-05-05 15:40:29 +02:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!user?.id) {
|
|
|
|
|
|
setTrainingHome(null)
|
2026-05-07 08:33:50 +02:00
|
|
|
|
setPhase0Stats(null)
|
2026-05-14 08:53:09 +02:00
|
|
|
|
setDashboardKpisErr(null)
|
2026-05-07 08:33:50 +02:00
|
|
|
|
return undefined
|
|
|
|
|
|
}
|
|
|
|
|
|
let cancelled = false
|
|
|
|
|
|
;(async () => {
|
2026-05-14 08:53:09 +02:00
|
|
|
|
setDashboardKpisErr(null)
|
2026-05-07 08:33:50 +02:00
|
|
|
|
try {
|
2026-05-14 07:47:27 +02:00
|
|
|
|
const data = await api.getDashboardKpis()
|
2026-05-14 08:53:09 +02:00
|
|
|
|
if (cancelled || !data || typeof data !== 'object') return
|
|
|
|
|
|
const th = data.training_home && typeof data.training_home === 'object' ? data.training_home : {}
|
|
|
|
|
|
setTrainingHome({
|
|
|
|
|
|
upcoming: Array.isArray(th.upcoming) ? th.upcoming : [],
|
|
|
|
|
|
reviewPending: Array.isArray(th.review_pending) ? th.review_pending : [],
|
|
|
|
|
|
plannedWithNotes: Array.isArray(th.planned_with_notes) ? th.planned_with_notes : [],
|
|
|
|
|
|
})
|
|
|
|
|
|
setPhase0Stats({
|
|
|
|
|
|
year: data.year,
|
|
|
|
|
|
draftCount: data.draft_count,
|
|
|
|
|
|
draftCapped: Boolean(data.draft_capped),
|
|
|
|
|
|
draftPreview: Array.isArray(data.draft_preview) ? data.draft_preview : [],
|
|
|
|
|
|
mineCount: data.mine_count ?? 0,
|
|
|
|
|
|
mineCapped: Boolean(data.mine_capped),
|
|
|
|
|
|
ytdCompletedCount: data.ytd_completed_count ?? 0,
|
|
|
|
|
|
ytdCapped: Boolean(data.ytd_capped),
|
|
|
|
|
|
})
|
2026-05-07 08:33:50 +02:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
if (!cancelled) {
|
2026-05-14 08:53:09 +02:00
|
|
|
|
console.error('Dashboard KPIs / Trainingsübersicht:', e)
|
|
|
|
|
|
setDashboardKpisErr(e.message || 'Konnte Dashboard-Daten nicht laden')
|
|
|
|
|
|
setTrainingHome(null)
|
2026-05-07 08:33:50 +02:00
|
|
|
|
setPhase0Stats(null)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
})()
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
cancelled = true
|
|
|
|
|
|
}
|
2026-05-13 16:34:38 +02:00
|
|
|
|
}, [user?.id, tenantClubDepKey])
|
2026-05-07 08:33:50 +02:00
|
|
|
|
|
2026-05-14 06:42:13 +02:00
|
|
|
|
if (authLoading) {
|
2026-04-21 14:56:16 +02:00
|
|
|
|
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>
|
2026-05-14 06:42:13 +02:00
|
|
|
|
{user ? <EmailVerificationBanner profile={user} /> : null}
|
2026-04-21 14:56:16 +02:00
|
|
|
|
|
2026-05-07 08:33:50 +02:00
|
|
|
|
{user?.id ? (
|
|
|
|
|
|
<>
|
2026-05-09 09:13:38 +02:00
|
|
|
|
<DashboardOrgInboxWidget />
|
2026-05-07 08:33:50 +02:00
|
|
|
|
<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-14 08:53:09 +02:00
|
|
|
|
{dashboardKpisErr ? (
|
2026-05-07 08:47:01 +02:00
|
|
|
|
<p className="dashboard-phase0-kpis__err" role="alert">
|
2026-05-14 08:53:09 +02:00
|
|
|
|
{dashboardKpisErr}
|
2026-05-07 08:47:01 +02:00
|
|
|
|
</p>
|
|
|
|
|
|
) : null}
|
2026-05-14 08:53:09 +02:00
|
|
|
|
{!dashboardKpisErr && !phase0Stats ? (
|
2026-05-07 08:47:01 +02:00
|
|
|
|
<div className="dashboard-phase0-kpis__loading muted">Zahlen werden geladen…</div>
|
|
|
|
|
|
) : null}
|
2026-05-14 08:53:09 +02:00
|
|
|
|
{!dashboardKpisErr && phase0Stats ? (
|
2026-05-07 08:47:01 +02:00
|
|
|
|
<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-14 08:53:09 +02:00
|
|
|
|
{!dashboardKpisErr && phase0Stats?.draftPreview?.length ? (
|
2026-05-07 09:20:19 +02:00
|
|
|
|
<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}
|
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>
|
2026-05-14 08:53:09 +02:00
|
|
|
|
{dashboardKpisErr ? (
|
|
|
|
|
|
<p className="dashboard-preview-card__err">{dashboardKpisErr}</p>
|
2026-05-07 08:33:50 +02:00
|
|
|
|
) : 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>
|
2026-05-14 08:53:09 +02:00
|
|
|
|
{dashboardKpisErr ? (
|
|
|
|
|
|
<p className="dashboard-preview-card__err">{dashboardKpisErr}</p>
|
2026-05-07 08:33:50 +02:00
|
|
|
|
) : 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-14 08:53:09 +02:00
|
|
|
|
{dashboardKpisErr ? (
|
|
|
|
|
|
<p className="dashboard-preview-card__err">{dashboardKpisErr}</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-19 11:02:03 +02:00
|
|
|
|
<Link to={`/planning/units/${u.id}/edit`} 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>
|
2026-05-07 10:19:55 +02:00
|
|
|
|
<DashboardTrainingVisibilityWidget user={user} />
|
2026-05-07 08:33:50 +02:00
|
|
|
|
</section>
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : null}
|
2026-04-21 14:56:16 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default Dashboard
|