- 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
14 KiB
Trainings Calendar View - Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add a calendar month view to the Trainings page as an alternative to grid/list views using react-big-calendar.
Architecture: Install react-big-calendar, create a CalendarView component with custom styling to match Shadcn theme, integrate with existing view toggle system, and add day-click popover for training details.
Tech Stack: react-big-calendar, date-fns, existing Shadcn UI components
File Structure
frontend/
├── src/app/(dashboard)/trainings/page.tsx # Modify: add calendar view toggle
├── src/components/ui/calendar-view.tsx # Create: CalendarView component
├── src/components/ui/calendar.css # Create: Calendar custom styles
├── package.json # Modify: add react-big-calendar, date-fns
Task 1: Install Dependencies
Files:
-
Modify:
frontend/package.json -
Step 1: Add dependencies
cd /Volumes/T3/Opencode/WrestleDesk/frontend
npm install react-big-calendar date-fns
npm install -D @types/react-big-calendar
- Step 2: Verify install
Run: npm list react-big-calendar date-fns
Expected: Both packages listed
Task 2: Create Calendar CSS
Files:
-
Create:
frontend/src/components/ui/calendar.css -
Step 1: Write calendar styles
.rbc-calendar {
font-family: inherit;
background: transparent;
}
.rbc-toolbar {
padding: 1rem 0;
margin-bottom: 1rem;
flex-wrap: wrap;
gap: 0.5rem;
}
.rbc-toolbar button {
color: hsl(var(--foreground));
border: 1px solid hsl(var(--border));
background: transparent;
border-radius: 0.375rem;
padding: 0.5rem 1rem;
font-size: 0.875rem;
transition: all 0.2s;
}
.rbc-toolbar button:hover {
background: hsl(var(--muted));
}
.rbc-toolbar button.rbc-active {
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
border-color: hsl(var(--primary));
}
.rbc-toolbar-label {
font-weight: 600;
font-size: 1.125rem;
}
.rbc-header {
padding: 0.75rem 0;
font-weight: 500;
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
border-bottom: 1px solid hsl(var(--border));
}
.rbc-month-view {
border: 1px solid hsl(var(--border));
border-radius: 0.75rem;
overflow: hidden;
}
.rbc-month-row {
border-bottom: 1px solid hsl(var(--border));
}
.rbc-month-row:last-child {
border-bottom: none;
}
.rbc-day-bg {
transition: background-color 0.2s;
}
.rbc-day-bg:hover {
background: hsl(var(--muted));
}
.rbc-today {
background: hsl(var(--primary) / 0.1);
}
.rbc-off-range-bg {
background: hsl(var(--muted) / 0.3);
}
.rbc-date-cell {
padding: 0.5rem;
text-align: right;
font-size: 0.875rem;
}
.rbc-date-cell > a {
color: inherit;
text-decoration: none;
}
.rbc-event {
background: hsl(var(--primary));
border: none;
border-radius: 0.25rem;
padding: 0.125rem 0.375rem;
font-size: 0.75rem;
color: hsl(var(--primary-foreground));
}
.rbc-event.rbc-selected {
background: hsl(var(--primary) / 0.8);
}
.rbc-show-more {
font-size: 0.75rem;
color: hsl(var(--primary));
font-weight: 500;
}
.rbc-overlay {
background: hsl(var(--background));
border: 1px solid hsl(var(--border));
border-radius: 0.5rem;
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1);
padding: 0.5rem;
min-width: 200px;
}
.rbc-overlay-header {
border-bottom: 1px solid hsl(var(--border));
padding: 0.5rem;
font-weight: 600;
font-size: 0.875rem;
}
Task 3: Create CalendarView Component
Files:
-
Create:
frontend/src/components/ui/calendar-view.tsx -
Step 1: Write CalendarView component
"use client"
import { useEffect, useState, useMemo, useCallback } from "react"
import { Calendar, dateFnsLocalizer, Views, SlotInfo } from "react-big-calendar"
import { format, parse, startOfWeek, getDay, startOfMonth, endOfMonth, addMonths, subMonths } from "date-fns"
import { de } from "date-fns/locale"
import "react-big-calendar/lib/css/react-big-calendar.css"
import "./calendar.css"
import { ITraining } from "@/lib/api"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import {
Clock, MapPin, Users, Eye, Pencil, Trash2, X
} from "lucide-react"
import { useAuth } from "@/lib/auth"
import { apiFetch, PaginatedResponse } from "@/lib/api"
import { toast } from "sonner"
const groupConfig = {
kids: { label: "Kinder", class: "bg-primary/10 text-primary border-primary/20" },
youth: { label: "Jugend", class: "bg-secondary/10 text-secondary border-secondary/20" },
adults: { label: "Erwachsene", class: "bg-accent/10 text-accent border-accent/20" },
all: { label: "Alle", class: "bg-muted text-muted-foreground" },
}
const locales = { "de": de }
const localizer = dateFnsLocalizer({
format,
parse,
startOfWeek: () => startOfWeek(new Date(), { locale: de }),
getDay,
locales,
})
interface CalendarViewProps {
onEdit: (training: ITraining) => void
onDelete: (id: number) => void
onView: (training: ITraining) => void
refreshTrigger: number
}
interface CalendarEvent {
id: number
title: string
start: Date
end: Date
resource: ITraining
}
export function CalendarView({ onEdit, onDelete, onView, refreshTrigger }: CalendarViewProps) {
const { token } = useAuth()
const [currentDate, setCurrentDate] = useState(new Date())
const [trainings, setTrainings] = useState<ITraining[]>([])
const [isLoading, setIsLoading] = useState(true)
const [selectedDayTrainings, setSelectedDayTrainings] = useState<ITraining[]>([])
const [selectedDay, setSelectedDay] = useState<Date | null>(null)
const [popoverOpen, setPopoverOpen] = useState(false)
const fetchTrainings = useCallback(async (date: Date) => {
if (!token) return
setIsLoading(true)
try {
const start = startOfMonth(subMonths(date, 1))
const end = endOfMonth(addMonths(date, 1))
const params = new URLSearchParams()
params.set("date_from", format(start, "yyyy-MM-dd"))
params.set("date_to", format(end, "yyyy-MM-dd"))
params.set("page_size", "100")
const data = await apiFetch<PaginatedResponse<ITraining>>(`/trainings/?${params.toString()}`, { token })
setTrainings(data.results || [])
} catch {
toast.error("Fehler beim Laden der Trainingseinheiten")
} finally {
setIsLoading(false)
}
}, [token])
useEffect(() => {
fetchTrainings(currentDate)
}, [fetchTrainings, currentDate, refreshTrigger])
const events: CalendarEvent[] = useMemo(() => {
return trainings
.filter(t => t.date)
.map(t => ({
id: t.id,
title: `${t.start_time || ""} - ${t.group ? groupConfig[t.group as keyof typeof groupConfig]?.label : ""}`,
start: new Date(`${t.date}T${t.start_time || "00:00"}`),
end: new Date(`${t.date}T${t.end_time || "23:59"}`),
resource: t,
}))
}, [trainings])
const handleSelectEvent = useCallback((event: CalendarEvent) => {
onView(event.resource)
}, [onView])
const handleSelectSlot = useCallback((slotInfo: SlotInfo) => {
const dayTrainings = trainings.filter(t => {
const trainingDate = new Date(t.date)
return format(trainingDate, "yyyy-MM-dd") === format(slotInfo.start, "yyyy-MM-dd")
})
if (dayTrainings.length > 0) {
setSelectedDay(slotInfo.start)
setSelectedDayTrainings(dayTrainings)
setPopoverOpen(true)
}
}, [trainings])
const eventStyleGetter = useCallback((event: CalendarEvent) => {
const group = event.resource.group
const config = groupConfig[group as keyof typeof groupConfig] || groupConfig.all
return {
style: {
backgroundColor: `hsl(var(--primary))`,
borderRadius: "0.25rem",
border: "none",
color: `hsl(var(--primary-foreground))`,
}
}
}, [])
const CustomEvent = ({ event }: { event: CalendarEvent }) => (
<div className="flex items-center gap-1 text-xs">
<span className="font-medium">{event.resource.start_time}</span>
</div>
)
const CustomToolbar = ({ label, onNavigate, onView, view }: any) => (
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => onNavigate("PREV")}>
‹
</Button>
<Button variant="outline" size="sm" onClick={() => onNavigate("TODAY")}>
Heute
</Button>
<Button variant="outline" size="sm" onClick={() => onNavigate("NEXT")}>
›
</Button>
</div>
<span className="text-lg font-semibold">{label}</span>
<div className="flex items-center gap-2">
<Button
variant={view === "month" ? "default" : "outline"}
size="sm"
onClick={() => onView("month")}
>
Monat
</Button>
</div>
</div>
)
return (
<div className="space-y-4">
<style>{`
.rbc-calendar { height: calc(100vh - 280px); min-height: 600px; }
`}</style>
<Calendar
localizer={localizer}
events={events}
startAccessor="start"
endAccessor="end"
view={Views.MONTH}
views={[Views.MONTH]}
date={currentDate}
onNavigate={setCurrentDate}
onSelectEvent={handleSelectEvent}
onSelectSlot={handleSelectSlot}
selectable
eventPropGetter={eventStyleGetter}
components={{
event: CustomEvent,
toolbar: CustomToolbar,
}}
messages={{
today: "Heute",
month: "Monat",
week: "Woche",
day: "Tag",
}}
/>
{popoverOpen && selectedDay && (
<div className="fixed inset-0 z-50" onClick={() => setPopoverOpen(false)}>
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 w-96">
<Card className="shadow-xl">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base">
{format(selectedDay, "EEEE, d. MMMM", { locale: de })}
</CardTitle>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setPopoverOpen(false)}
>
<X className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent className="max-h-64 overflow-y-auto space-y-2">
{selectedDayTrainings.map(training => (
<div
key={training.id}
className="flex items-center justify-between p-2 rounded-lg border hover:bg-muted/50 transition-colors cursor-pointer"
onClick={() => {
onView(training)
setPopoverOpen(false)
}}
>
<div className="flex items-center gap-3">
<div className="text-sm font-medium">
{training.start_time} - {training.end_time}
</div>
<Badge
className={groupConfig[training.group as keyof typeof groupConfig]?.class}
variant="secondary"
>
{groupConfig[training.group as keyof typeof groupConfig]?.label}
</Badge>
</div>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Users className="h-3 w-3" />
{training.attendance_count || 0}
</div>
</div>
))}
</CardContent>
</Card>
</div>
</div>
)}
</div>
)
}
Task 4: Integrate Calendar into Trainings Page
Files:
-
Modify:
frontend/src/app/(dashboard)/trainings/page.tsx -
Step 1: Add imports
Add to imports section:
import { CalendarView } from "@/components/ui/calendar-view"
import { Calendar } from "lucide-react"
- Step 2: Update viewMode state
Change line ~50:
const [viewMode, setViewMode] = useState<"grid" | "list" | "calendar">("grid")
- Step 3: Update toggle buttons
Find the existing toggle (around line 420-440) and update to:
<div className="flex border rounded-lg">
<button
onClick={() => setViewMode("grid")}
className={`p-2 ${viewMode === "grid" ? "bg-muted" : "hover:bg-muted/50"} rounded-l-lg transition-colors`}
>
<LayoutGrid className="w-4 h-4" />
</button>
<button
onClick={() => setViewMode("list")}
className={`p-2 ${viewMode === "list" ? "bg-muted" : "hover:bg-muted/50"} transition-colors`}
>
<List className="w-4 h-4" />
</button>
<button
onClick={() => setViewMode("calendar")}
className={`p-2 ${viewMode === "calendar" ? "bg-muted" : "hover:bg-muted/50"} rounded-r-lg transition-colors`}
>
<Calendar className="w-4 h-4" />
</button>
</div>
- Step 4: Add CalendarView rendering
After the existing grid/list views (around line 525), add:
{viewMode === "calendar" && (
<FadeIn delay={0.1}>
<CalendarView
onEdit={handleEdit}
onDelete={(id) => setDeleteId(id)}
onView={(training) => router.push(`/trainings/${training.id}`)}
refreshTrigger={trainings.length}
/>
</FadeIn>
)}
- Step 5: Hide pagination in calendar view
Find pagination section and update:
{totalPages > 1 && viewMode !== "calendar" && (
<Pagination ... />
)}
Task 5: Build and Test
- Step 1: Run build
Run: cd /Volumes/T3/Opencode/WrestleDesk/frontend && npm run build
Expected: SUCCESS
- Step 2: Test in browser
Run: cd /Volumes/T3/Opencode/WrestleDesk/frontend && npm run dev
Navigate to: http://localhost:3000/trainings
- Click calendar icon in toggle
- Verify calendar renders with month navigation
- Click on a day to see trainings popover
- Navigate between months
- Click "Heute" to return to current month
Task 6: Commit
git add -A
git commit -m "feat(trainings): add calendar month view with react-big-calendar"