3fefc550fe
- 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
204 lines
8.8 KiB
TypeScript
204 lines
8.8 KiB
TypeScript
"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>
|
|
</>
|
|
)
|
|
}
|