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:
Andrej Spielmann
2026-03-26 13:24:57 +01:00
commit 3fefc550fe
256 changed files with 38295 additions and 0 deletions
@@ -0,0 +1,203 @@
"use client"
import { useState } from "react"
import { useAuth } from "@/lib/auth"
import { apiFetch, ITrainingHomeworkAssignment, PaginatedResponse } from "@/lib/api"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Progress } from "@/components/ui/progress"
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"
import { FadeIn } from "@/components/ui/animations"
import { Check, X, Loader2, BookOpen, Calendar, Dumbbell } from "lucide-react"
import { toast } from "sonner"
const groupConfig = {
kids: { label: "Kinder", class: "bg-primary/10 text-primary" },
youth: { label: "Jugend", class: "bg-secondary/10 text-secondary" },
adults: { label: "Erwachsene", class: "bg-accent/10 text-accent" },
}
const categoryConfig = {
warmup: { label: "Aufwärmen", class: "bg-primary/10 text-primary" },
kraft: { label: "Kraft", class: "bg-destructive/10 text-destructive" },
technik: { label: "Technik", class: "bg-secondary/10 text-secondary" },
ausdauer: { label: "Ausdauer", class: "bg-accent/10 text-accent" },
spiele: { label: "Spiele", class: "bg-muted text-muted-foreground" },
cool_down: { label: "Abkühlung", class: "bg-primary/5 text-primary" },
}
interface GroupedByWrestler {
wrestlerId: number
wrestlerName: string
wrestlerGroup: string
assignments: ITrainingHomeworkAssignment[]
completedCount: number
totalCount: number
}
interface WrestlerCentricViewProps {
assignments: ITrainingHomeworkAssignment[]
onToggleComplete: (id: number, current: boolean) => void
togglingId: number | null
}
export function WrestlerCentricView({ assignments, onToggleComplete, togglingId }: WrestlerCentricViewProps) {
const [selectedWrestler, setSelectedWrestler] = useState<GroupedByWrestler | null>(null)
// Group assignments by wrestler
const groupedByWrestler: GroupedByWrestler[] = assignments.reduce((acc, assignment) => {
const existing = acc.find(g => g.wrestlerId === assignment.wrestler)
if (existing) {
existing.assignments.push(assignment)
if (assignment.is_completed) existing.completedCount++
existing.totalCount++
} else {
acc.push({
wrestlerId: assignment.wrestler,
wrestlerName: assignment.wrestler_name,
wrestlerGroup: assignment.wrestler_group,
assignments: [assignment],
completedCount: assignment.is_completed ? 1 : 0,
totalCount: 1,
})
}
return acc
}, [] as GroupedByWrestler[])
// Sort wrestlers by: those with incomplete homework first, then by name
groupedByWrestler.sort((a, b) => {
const aIncomplete = a.totalCount - a.completedCount
const bIncomplete = b.totalCount - b.completedCount
if (aIncomplete !== bIncomplete) return bIncomplete - aIncomplete
return a.wrestlerName.localeCompare(b.wrestlerName)
})
const getProgress = (wrestler: GroupedByWrestler) => {
if (wrestler.totalCount === 0) return 0
return Math.round((wrestler.completedCount / wrestler.totalCount) * 100)
}
return (
<>
<div className="space-y-4">
{groupedByWrestler.map((wrestler) => {
const progress = getProgress(wrestler)
const isComplete = progress === 100
return (
<FadeIn key={wrestler.wrestlerId}>
<Card
className={`cursor-pointer hover:shadow-md transition-all ${isComplete ? 'opacity-75' : ''}`}
onClick={() => setSelectedWrestler(wrestler)}
>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Avatar size="default">
<AvatarFallback className="bg-primary/10 text-primary">
{wrestler.wrestlerName?.split(" ").map(n => n[0]).slice(0,2).join("")}
</AvatarFallback>
</Avatar>
<div>
<CardTitle className="text-base">{wrestler.wrestlerName}</CardTitle>
<Badge className={groupConfig[wrestler.wrestlerGroup as keyof typeof groupConfig]?.class}>
{groupConfig[wrestler.wrestlerGroup as keyof typeof groupConfig]?.label}
</Badge>
</div>
</div>
<div className="text-right">
<div className={`text-lg font-bold ${isComplete ? 'text-green-600' : 'text-orange-500'}`}>
{wrestler.completedCount}/{wrestler.totalCount}
</div>
<div className="text-xs text-muted-foreground">
{isComplete ? 'Alle erledigt' : 'Offen'}
</div>
</div>
</div>
</CardHeader>
<CardContent>
<div className="flex items-center gap-3">
<Progress value={progress} className="flex-1 h-2" />
<span className="text-sm font-medium w-12">{progress}%</span>
</div>
<div className="mt-2 text-xs text-muted-foreground">
Letztes Training: {wrestler.assignments[0]?.training_date
? new Date(wrestler.assignments[0].training_date).toLocaleDateString("de-DE")
: "Keine"}
</div>
</CardContent>
</Card>
</FadeIn>
)
})}
</div>
{/* Detail Sheet */}
<Sheet open={!!selectedWrestler} onOpenChange={() => setSelectedWrestler(null)}>
<SheetContent side="right" className="w-[400px] sm:max-w-[400px]">
<SheetHeader>
<SheetTitle>
{selectedWrestler?.wrestlerName}
</SheetTitle>
</SheetHeader>
{selectedWrestler && (
<div className="mt-4 space-y-4">
{selectedWrestler.assignments
.sort((a, b) => new Date(b.training_date).getTime() - new Date(a.training_date).getTime())
.map(assignment => (
<div key={assignment.id} className="border rounded-lg p-3">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Badge variant="outline">
{new Date(assignment.training_date).toLocaleDateString("de-DE")}
</Badge>
<Badge
variant={assignment.is_completed ? "default" : "secondary"}
className={assignment.is_completed ? "bg-green-600" : ""}
>
{assignment.is_completed ? "Erledigt" : "Offen"}
</Badge>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => onToggleComplete(assignment.id, assignment.is_completed)}
disabled={togglingId === assignment.id}
>
{togglingId === assignment.id ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : assignment.is_completed ? (
<X className="w-4 h-4 text-red-500" />
) : (
<Check className="w-4 h-4 text-green-500" />
)}
</Button>
</div>
<div className="space-y-1">
{assignment.exercises.map(ex => (
<div key={ex.id} className="flex items-center gap-2 text-sm">
<Badge className={categoryConfig[ex.exercise_category as keyof typeof categoryConfig]?.class}>
{categoryConfig[ex.exercise_category as keyof typeof categoryConfig]?.label}
</Badge>
<span>{ex.exercise_name}</span>
{ex.reps && <span className="text-muted-foreground">{ex.reps}x</span>}
{ex.time_minutes && <span className="text-muted-foreground">{ex.time_minutes}s</span>}
</div>
))}
</div>
{assignment.notes && (
<p className="mt-2 text-xs text-muted-foreground border-t pt-2">
{assignment.notes}
</p>
)}
</div>
))}
</div>
)}
</SheetContent>
</Sheet>
</>
)
}