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
This commit is contained in:
@@ -0,0 +1,306 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user