Files
WrestleDesk/frontend/src/app/(dashboard)/dashboard/page.tsx
T
Andrej Spielmann 3fefc550fe Initial commit: WrestleDesk full project
- Django backend with DRF (clubs, wrestlers, trainers, exercises, templates, trainings, homework, locations, leistungstest)
- Next.js 16 frontend with React, Shadcn UI, Tailwind
- JWT authentication
- Full CRUD for all entities
- Calendar view for trainings
- Homework management system
- Leistungstest tracking
2026-03-26 13:24:57 +01:00

307 lines
13 KiB
TypeScript

"use client"
import { useEffect, useState } from "react"
import { useAuth } from "@/lib/auth"
import { apiFetch, IDashboardStats } from "@/lib/api"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { DashboardSkeleton } from "@/components/ui/skeletons"
import { FadeIn, StaggeredList, listItemVariants, CardHover } from "@/components/ui/animations"
import { motion } from "framer-motion"
import { Users, UserCog, CalendarDays, BookOpen, TrendingUp, Trophy } from "lucide-react"
import { Badge } from "@/components/ui/badge"
const groupConfig = {
kids: { label: "Kinder", color: "bg-blue-500", bgColor: "bg-blue-500/10", textColor: "text-blue-500" },
youth: { label: "Jugend", color: "bg-purple-500", bgColor: "bg-purple-500/10", textColor: "text-purple-500" },
adults: { label: "Erwachsene", color: "bg-orange-500", bgColor: "bg-orange-500/10", textColor: "text-orange-500" },
inactive: { label: "Inaktiv", color: "bg-gray-400", bgColor: "bg-gray-400/10", textColor: "text-gray-500" },
}
const groupOrder = ["kids", "youth", "adults", "inactive"] as const
export default function DashboardPage() {
const { token, user } = useAuth()
const [stats, setStats] = useState<IDashboardStats | null>(null)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
if (!token) return
const fetchStats = async () => {
setIsLoading(true)
try {
const data = await apiFetch<IDashboardStats>('/stats/dashboard/', { token })
setStats(data)
} catch (error) {
console.error("Failed to fetch stats:", error)
} finally {
setIsLoading(false)
}
}
fetchStats()
}, [token])
if (isLoading) {
return <DashboardSkeleton />
}
const maxActivity = stats?.activity ? Math.max(...stats.activity.map(a => a.count), 1) : 1
return (
<div className="space-y-8">
<FadeIn>
<div className="space-y-1">
<h1 className="text-2xl font-bold tracking-tight">Dashboard</h1>
<p className="text-muted-foreground">Willkommen zurück, {user?.username}</p>
</div>
</FadeIn>
<FadeIn delay={0.05}>
<StaggeredList staggerDelay={0.08} className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
<CardHover>
<Card className="cursor-pointer">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Ringer</CardTitle>
<motion.div whileHover={{ rotate: 10, scale: 1.1 }} transition={{ duration: 0.2 }}>
<Users className="h-5 w-5 text-primary" />
</motion.div>
</CardHeader>
<CardContent className="pb-4">
<div className="text-3xl font-bold">{stats?.wrestlers.total || 0}</div>
<p className="text-xs text-muted-foreground mt-1">
+{stats?.wrestlers.this_week || 0} diese Woche
</p>
</CardContent>
</Card>
</CardHover>
<CardHover>
<Card className="cursor-pointer">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Trainer</CardTitle>
<motion.div whileHover={{ rotate: 10, scale: 1.1 }} transition={{ duration: 0.2 }}>
<UserCog className="h-5 w-5 text-secondary" />
</motion.div>
</CardHeader>
<CardContent className="pb-4">
<div className="text-3xl font-bold">{stats?.trainers.total || 0}</div>
<p className="text-xs text-muted-foreground mt-1">
Aktiv: {stats?.trainers.active || 0}
</p>
</CardContent>
</Card>
</CardHover>
<CardHover>
<Card className="cursor-pointer">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Trainings</CardTitle>
<motion.div whileHover={{ rotate: 10, scale: 1.1 }} transition={{ duration: 0.2 }}>
<CalendarDays className="h-5 w-5 text-accent" />
</motion.div>
</CardHeader>
<CardContent className="pb-4">
<div className="text-3xl font-bold">{stats?.trainings.total || 0}</div>
<p className="text-xs text-muted-foreground mt-1">
+{stats?.trainings.this_week || 0} diese Woche
</p>
</CardContent>
</Card>
</CardHover>
<CardHover>
<Card className="cursor-pointer">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Hausaufgaben</CardTitle>
<motion.div whileHover={{ rotate: 10, scale: 1.1 }} transition={{ duration: 0.2 }}>
<BookOpen className="h-5 w-5 text-primary" />
</motion.div>
</CardHeader>
<CardContent className="pb-4">
<div className="text-3xl font-bold">{stats?.homework.open || 0}</div>
<p className="text-xs text-muted-foreground mt-1">
offen
</p>
</CardContent>
</Card>
</CardHover>
</StaggeredList>
</FadeIn>
<div className="grid gap-6 lg:grid-cols-2">
<FadeIn delay={0.1}>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Users className="h-4 w-4 text-secondary" />
Teilnahme diese Woche
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{groupOrder.filter(g => g !== "inactive").map((group) => {
const data = stats?.attendance.this_week[group]
const config = groupConfig[group]
return (
<div key={group} className="space-y-2">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${config.color}`} />
<span>{config.label}</span>
</div>
<span className="font-medium">
{data?.attended || 0}/{data?.total || 0}
</span>
</div>
<div className="h-2 bg-muted rounded-full overflow-hidden">
<div
className={`h-full ${config.color} transition-all duration-500 rounded-full`}
style={{ width: `${data?.percent || 0}%` }}
/>
</div>
</div>
)
})}
<div className="pt-2 border-t">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Ø Teilnehmer</span>
<span className="font-medium">
{stats?.attendance.average || 0}/{stats?.attendance.expected || 0}
</span>
</div>
</div>
</CardContent>
</Card>
</FadeIn>
<FadeIn delay={0.15}>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<TrendingUp className="h-4 w-4 text-primary" />
Training Aktivität
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-end gap-1 h-24">
{stats?.activity.map((day, i) => (
<div key={i} className="flex-1 flex flex-col items-center gap-1">
<div
className="w-full bg-primary/20 hover:bg-primary/30 transition-colors rounded-t"
style={{ height: `${Math.max((day.count / maxActivity) * 100, 4)}%` }}
/>
</div>
))}
</div>
<p className="text-xs text-muted-foreground mt-2 text-center">
Letzte 14 Tage
</p>
</CardContent>
</Card>
</FadeIn>
</div>
<FadeIn delay={0.2}>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<BookOpen className="h-4 w-4 text-green-500" />
Hausaufgaben Erledigung
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center gap-4">
<div className="flex-1">
<div className="h-4 bg-muted rounded-full overflow-hidden flex">
<div
className="h-full bg-green-500 transition-all duration-500"
style={{
width: `${stats?.homework.completed ?
(stats.homework.completed / (stats.homework.open + stats.homework.completed) * 100) : 0}%`
}}
/>
</div>
</div>
<span className="text-sm font-medium whitespace-nowrap">
{stats?.homework.completed || 0} ({stats?.homework.completed ?
Math.round(stats.homework.completed / (stats.homework.open + stats.homework.completed) * 100) : 0}%)
</span>
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>Offen: {stats?.homework.open || 0}</span>
<span>Erledigt: {stats?.homework.completed || 0}</span>
</div>
</CardContent>
</Card>
</FadeIn>
<div className="grid gap-6 lg:grid-cols-2">
<FadeIn delay={0.25}>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Users className="h-4 w-4 text-accent" />
Ringer nach Gruppe
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{groupOrder.map((group) => {
const count = stats?.wrestlers_by_group[group] || 0
const config = groupConfig[group]
const total = stats?.wrestlers.total || 1
return (
<div key={group} className="space-y-2">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${config.color}`} />
<span>{config.label}</span>
</div>
<span className="font-medium">{count}</span>
</div>
<div className="h-2 bg-muted rounded-full overflow-hidden">
<div
className={`h-full ${config.color} transition-all duration-500 rounded-full`}
style={{ width: `${(count / total) * 100}%` }}
/>
</div>
</div>
)
})}
</CardContent>
</Card>
</FadeIn>
<FadeIn delay={0.3}>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Trophy className="h-4 w-4 text-yellow-500" />
Top Trainer
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{stats?.top_trainers && stats.top_trainers.length > 0 ? (
stats.top_trainers.map((trainer, i) => (
<div key={i} className="flex items-center gap-3">
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold ${
i === 0 ? "bg-yellow-100 text-yellow-700" :
i === 1 ? "bg-gray-100 text-gray-700" :
i === 2 ? "bg-orange-100 text-orange-700" :
"bg-muted text-muted-foreground"
}`}>
{i + 1}
</div>
<span className="flex-1 font-medium">{trainer.name}</span>
<Badge variant="secondary">{trainer.training_count} Training</Badge>
</div>
))
) : (
<p className="text-sm text-muted-foreground">Keine Trainer vorhanden</p>
)}
</CardContent>
</Card>
</FadeIn>
</div>
</div>
)
}