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,529 @@
|
||||
# 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**
|
||||
|
||||
```bash
|
||||
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**
|
||||
|
||||
```css
|
||||
.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**
|
||||
|
||||
```tsx
|
||||
"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:
|
||||
```tsx
|
||||
import { CalendarView } from "@/components/ui/calendar-view"
|
||||
import { Calendar } from "lucide-react"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update viewMode state**
|
||||
|
||||
Change line ~50:
|
||||
```tsx
|
||||
const [viewMode, setViewMode] = useState<"grid" | "list" | "calendar">("grid")
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update toggle buttons**
|
||||
|
||||
Find the existing toggle (around line 420-440) and update to:
|
||||
```tsx
|
||||
<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:
|
||||
```tsx
|
||||
{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:
|
||||
```tsx
|
||||
{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
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat(trainings): add calendar month view with react-big-calendar"
|
||||
```
|
||||
Reference in New Issue
Block a user