# AGENTS.md - WrestleDesk
## Project Overview
**Backend-First Development**: This project uses a backend-first approach. Agents should focus on Django backend development before frontend work.
### Tech Stack
- **Backend**: Django + Django REST Framework + PostgreSQL
- **Frontend**: Next.js 16 + React + Shadcn UI + Tailwind CSS
- **Auth**: JWT via djangorestframework-simplejwt
---
## Development Commands
### Backend (Django)
```bash
cd backend
pip install -r requirements.txt
python manage.py migrate
python manage.py makemigrations
python manage.py runserver
python manage.py test
python manage.py shell
python manage.py createsuperuser
python manage.py seed_data
```
### Frontend (Next.js)
```bash
cd frontend
npm install
npm run dev
npm run build
npm run start
npm run lint
npm run typecheck
```
---
## Frontend Setup
### Create New Next.js Project
```bash
npx create-next-app@latest frontend --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
cd frontend
```
### Initialize Shadcn UI
```bash
npx shadcn@latest init
# When prompted:
# - Style: default
# - Base color: zinc (or slate)
# - CSS file: src/app/globals.css
# - CSS variables: yes
# - Dark mode: class
# - SSR: yes
```
### Add Shadcn Components
```bash
npx shadcn@latest add button card dialog sheet table badge input select dropdown-menu toast tooltip avatar separator tabs accordion alert skeleton progress switch checkbox label scroll-area
```
---
## Frontend Project Structure
```
frontend/
├── src/
│ ├── app/
│ │ ├── (auth)/
│ │ │ └── login/
│ │ │ └── page.tsx
│ │ ├── (dashboard)/
│ │ │ ├── layout.tsx
│ │ │ ├── page.tsx # Dashboard
│ │ │ ├── wrestlers/
│ │ │ │ └── page.tsx # Wrestlers CRUD
│ │ │ ├── trainers/
│ │ │ │ └── page.tsx # Trainers CRUD
│ │ │ ├── exercises/
│ │ │ │ └── page.tsx # Exercises CRUD
│ │ │ ├── trainings/
│ │ │ │ ├── page.tsx # Trainings list (Grid/List/Calendar)
│ │ │ │ └── [id]/
│ │ │ │ └── page.tsx # Training detail + attendance + homework
│ │ │ ├── templates/
│ │ │ │ └── page.tsx # Templates CRUD
│ │ │ ├── homework/
│ │ │ │ └── page.tsx # Homework assignments overview
│ │ │ └── clubs/
│ │ │ └── page.tsx # Clubs CRUD
│ │ ├── api/
│ │ │ └── [...slug]/
│ │ │ └── route.ts # API proxy
│ │ ├── layout.tsx
│ │ ├── globals.css
│ │ └── providers.tsx
│ ├── components/
│ │ ├── ui/ # Shadcn components
│ │ ├── layout/ # Sidebar
│ │ ├── modal.tsx # Reusable modal
│ │ ├── animations.tsx # FadeIn wrapper
│ │ ├── skeletons.tsx # Loading states
│ │ ├── empty-state.tsx # Empty state
│ │ └── pagination.tsx # Pagination
│ ├── lib/
│ │ ├── api.ts # API client + types
│ │ ├── auth.ts # Zustand auth store
│ │ └── utils.ts # cn() helper
│ └── hooks/ # React hooks
├── next.config.js
├── tailwind.config.ts
└── package.json
```
---
## Implemented Features Status
### ✅ Fully Working
| Feature | Backend | Frontend | Notes |
|---------|---------|----------|-------|
| **Authentication** | ✅ JWT login/register | ✅ Login page with zustand | Rate limited, persistent storage |
| **Dashboard** | ✅ Stats endpoints | ✅ Stats cards + animations | Shows wrestlers, trainers, trainings, open homework |
| **Wrestlers CRUD** | ✅ Full API | ✅ Full UI with photo upload | Filters, pagination, FormData |
| **Trainers CRUD** | ✅ Full API | ✅ Full UI with photo upload | Filters, pagination |
| **Exercises CRUD** | ✅ Full API | ✅ Full UI with categories | Categories: warmup, kraft, technik, ausdauer, spiele, cool_down |
| **Clubs CRUD** | ✅ Full API | ✅ Full UI with logo upload | Wrestler count displayed |
| **Trainings** | ✅ Full API | ✅ Grid/List/Calendar view | Attendance management working |
| **Training Detail** | ✅ Full API | ✅ 2-column layout | Participants + Homework side-by-side |
| **Homework System** | ✅ Complete | ✅ Full implementation | Training-based homework with completion tracking |
| **Templates** | ✅ Full API | ✅ Basic CRUD | Exercise association via through table |
### ⚠️ Known Issues
| Issue | Status | Priority |
|-------|--------|----------|
| **Wrestlers FormData** | 🔴 Bug in handleSubmit - references undefined variables | High |
| **Locations Page** | 🟡 Backend exists, frontend missing | Medium |
| **Settings Page** | 🟡 Sidebar links to /settings but no page | Low |
---
## Architecture Decisions
### ❌ NOT Required: Club-Level Data Isolation
**Important**: Club-level data isolation is NOT a requirement for this project. All users can see all data across all clubs.
- The `ClubFilterBackend` and `ClubLevelPermission` exist in `utils/permissions.py` but are NOT enforced
- ViewSets use only `IsAuthenticated` permission
- This is intentional - the system is designed for open data access
### Multi-Club Training Feature
Trainers and wrestlers from OTHER clubs CAN be added to training sessions:
- `GET /api/v1/trainers/available_for_training/` - Returns ALL trainers
- `GET /api/v1/wrestlers/available_for_training/` - Returns ALL wrestlers
This allows cross-club collaboration while maintaining separate club entities.
---
## React Patterns
### Component Structure
```tsx
"use client"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
interface Props {
title: string
}
export function ComponentName({ title }: Props) {
const [loading, setLoading] = useState(false)
return (
{title}
)
}
```
### Using Shadcn Components
```tsx
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
```
### Toast Notifications (Sonner)
```tsx
import { toast } from "sonner"
toast.success("Ringer erstellt")
toast.error("Fehler beim Speichern")
```
---
## Next.js/React Development Guidelines
#### Core Principles
- **Server Components First**: Use Server Components by default; add "use client" only when interactivity is needed
- **TypeScript Strict Mode**: Enable strict TypeScript everywhere; avoid `any` type
- **Component Composition**: Prefer small, reusable components over monolithic ones
- **Data Fetching**: Use React Query (TanStack Query) for client-side data fetching; Server Components for initial data
- **Performance**: Lazy load routes and heavy components; use `next/image` for optimized images
#### Project Structure Best Practices
**File Organization**
```
src/
├── app/ # App Router (file-based routing)
│ ├── (auth)/ # Route groups (no URL prefix)
│ ├── (dashboard)/ # Dashboard routes
│ ├── api/ # API routes (proxy to backend)
│ └── layout.tsx # Root layout
├── components/
│ ├── ui/ # Shadcn/ui components (ALWAYS manually managed)
│ ├── forms/ # Form components with react-hook-form + zod
│ ├── data-display/ # Tables, lists, cards
│ └── layout/ # Sidebar, header, navigation
├── hooks/ # Custom React hooks
├── lib/ # Utilities, API client, stores
├── types/ # Shared TypeScript types
└── styles/ # Global styles
```
**Naming Conventions**
- Components: `PascalCase.tsx` (e.g., `WrestlerCard.tsx`)
- Hooks: `camelCase.ts` with `use` prefix (e.g., `useWrestlers.ts`)
- Utils: `camelCase.ts` (e.g., `formatDate.ts`)
- Types: `PascalCase.ts` or inline interfaces
- Constants: `UPPERCASE_WITH_UNDERSCORE`
#### Component Development
**Component Anatomy**
```tsx
// 1. "use client" ONLY if needed for interactivity
"use client"
import { useState } from "react"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
import { Button } from "@/components/ui/button"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { toast } from "sonner"
// 2. Define schema with zod
const formSchema = z.object({
firstName: z.string().min(1, "Vorname ist erforderlich"),
lastName: z.string().min(1, "Nachname ist erforderlich"),
})
type FormValues = z.infer
// 3. Define interface BEFORE component
interface WrestlerFormProps {
onSubmit: (values: FormValues) => Promise
defaultValues?: Partial
}
// 4. Component with explicit prop types
export function WrestlerForm({ onSubmit, defaultValues }: WrestlerFormProps) {
const [isLoading, setIsLoading] = useState(false)
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: defaultValues ?? {
firstName: "",
lastName: "",
},
})
async function handleSubmit(values: FormValues) {
setIsLoading(true)
try {
await onSubmit(values)
toast.success("Ringer gespeichert")
} catch (error) {
toast.error("Fehler beim Speichern")
} finally {
setIsLoading(false)
}
}
return (
)
}
```
**When to Use "use client"**
| Use Client | Server Component |
|------------|-----------------|
| useState, useEffect | Data fetching (Server Components) |
| Event handlers | Static UI rendering |
| Browser APIs | SEO metadata |
| Third-party hooks | Static forms (no validation) |
| Real-time subscriptions | Static pages |
#### State Management Strategy
**Local State**: Use `useState` for component-specific UI state
```tsx
const [isOpen, setIsOpen] = useState(false)
const [selectedId, setSelectedId] = useState(null)
```
**Server State (TanStack Query)**: Use for all API data
```tsx
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { apiFetch } from "@/lib/api"
export function useWrestlers(filters?: Record) {
return useQuery({
queryKey: ["wrestlers", filters],
queryFn: () => apiFetch<{ results: Wrestler[] }>("/wrestlers/", { params: filters }),
})
}
export function useCreateWrestler() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: CreateWrestlerInput) =>
apiFetch("/wrestlers/", { method: "POST", body: data }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["wrestlers"] })
toast.success("Ringer erstellt")
},
})
}
```
**Global UI State (Zustand)**: Use for auth, theme, modals
```tsx
// lib/auth-store.ts
import { create } from "zustand"
import { persist } from "zustand/middleware"
interface AuthState {
token: string | null
user: User | null
setAuth: (token: string, user: User) => void
logout: () => void
}
export const useAuthStore = create()(
persist(
(set) => ({
token: null,
user: null,
setAuth: (token, user) => set({ token, user }),
logout: () => set({ token: null, user: null }),
}),
{ name: "auth-storage" }
)
)
```
#### Form Handling
**Use react-hook-form + zod for all forms**
```tsx
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
const schema = z.object({
email: z.string().email("Ungültige E-Mail"),
password: z.string().min(8, "Mindestens 8 Zeichen"),
})
type FormData = z.infer
export function LoginForm() {
const form = useForm({
resolver: zodResolver(schema),
defaultValues: { email: "", password: "" },
})
// ... form implementation
}
```
**FormData for File Uploads**
```tsx
const formData = new FormData()
formData.append("first_name", data.firstName)
formData.append("photo", photoFile) // File object directly
await apiFetch("/wrestlers/", {
method: "POST",
body: formData,
// Don't set Content-Type header - browser sets it with boundary
})
```
#### Data Fetching Patterns
**Next.js App Router - Server Components (Preferred)**
```tsx
// app/(dashboard)/wrestlers/page.tsx
import { apiFetch } from "@/lib/api"
import { WrestlerList } from "./wrestler-list"
export default async function WrestlersPage() {
const data = await apiFetch<{ results: Wrestler[] }>("/wrestlers/")
return
}
```
**Client Components with TanStack Query**
```tsx
// components/wrestler-list.tsx
"use client"
import { useQuery } from "@tanstack/react-query"
import { apiFetch } from "@/lib/api"
export function WrestlerList() {
const { data, isLoading, error } = useQuery({
queryKey: ["wrestlers"],
queryFn: () => apiFetch<{ results: Wrestler[] }>("/wrestlers/"),
})
if (isLoading) return
if (error) return
Fehler beim Laden
return (
{data?.results.map((wrestler) => (
))}
)
}
```
#### Reactive Filter + Table Pattern
Pages with filters (search, dropdowns) and a data table must use a **two-component split** to prevent full-page flashes when typing:
```
PageNamePage → Header + Filter bar (stable, mounted once)
PageNameTable → Table + Pagination (reactive, updates independently)
```
**The Problem:** If `isLoading` is shared state at the page level, typing in the search input triggers a fetch which sets `isLoading = true`, and if that renders `` at the top of the return statement, the entire page goes blank on every keystroke.
**The Fix:**
1. **Main page component** holds: header, filter bar, modal state, total count
2. **Table sub-component** holds: table data state, `isTableLoading` state, fetch logic
3. Filter changes → only the table component re-renders, not the whole page
4. Table sub-component shows its own skeleton rows during loading, never replaces the page
**exercises/page.tsx pattern:**
```tsx
function ExerciseTable({ filters, token, onEdit, onDelete }) {
const [exercises, setExercises] = useState([])
const [isTableLoading, setIsTableLoading] = useState(false)
useEffect(() => {
setIsTableLoading(true)
fetchExercises(filters).then(data => {
setExercises(data.results)
setIsTableLoading(false)
})
}, [filters])
if (isTableLoading) {
return (
)
}
return
}
export default function ExercisesPage() {
const [filters, setFilters] = useState(DEFAULT_FILTERS)
const [totalCount, setTotalCount] = useState(0)
if (isLoading) return // initial load only
return (
Header + Add ButtonFilter Bar
)
}
```
**Key rules:**
- `isLoading` (initial page load) → `` returned BEFORE the main layout
- `isTableLoading` (filter/pagination changes) → table shows inline skeleton, page layout stays
- NEVER `return ` inside the main return's JSX tree — it must be an early return before the layout renders
- Table component is a separate function component, not inline JSX
- Parent page's `isLoading` initializes to `true`, becomes `false` after first mount effect
#### Delete Confirmation Modal Pattern
For destructive actions (delete) on list pages, always show a confirmation modal before executing the delete. This prevents accidental deletions.
**State (in the page component):**
```tsx
const [deleteId, setDeleteId] = useState(null)
const [isDeleting, setIsDeleting] = useState(false)
```
**Delete handler (sets confirmation state instead of deleting directly):**
```tsx
const handleDelete = (id: number) => {
setDeleteId(id)
}
const confirmDelete = async () => {
if (!deleteId) return
setIsDeleting(true)
try {
await apiFetch(`/resource/${deleteId}/`, { method: "DELETE", token: token! })
toast.success("Erfolgreich gelöscht")
setDeleteId(null)
fetchData()
} catch {
toast.error("Fehler beim Löschen")
} finally {
setIsDeleting(false)
}
}
```
**Modal (at bottom of page, before closing tag):**
```tsx
!open && setDeleteId(null)}
title="[Resource] löschen"
description="Bist du sicher, dass du diesen Eintrag löschen möchtest?"
size="sm"
footer={
<>
>
}
>
```
**Delete button in table (calls handleDelete, not the API directly):**
```tsx
```
**Key rules:**
- Never call the API directly from a delete button — always go through the confirmation flow
- The modal shows `size="sm"` for simple confirmations (just a title + description)
- The modal body is empty (``) — the description text is sufficient
- `isDeleting` prevents double-submit while the API call is in flight
#### Error Handling
**API Error Handling**
```tsx
async function handleSubmit(values: FormValues) {
try {
await createWrestler(values)
toast.success("Erfolg!")
} catch (error) {
if (error instanceof ApiError) {
// Handle field errors
if (error.status === 422) {
form.setError("firstName", { message: error.errors.first_name?.[0] })
return
}
}
toast.error("Ein Fehler ist aufgetreten")
}
}
```
**Error Boundaries**
```tsx
"use client"
import { Component, type ReactNode } from "react"
interface Props {
children: ReactNode
fallback: ReactNode
}
interface State {
hasError: boolean
}
export class ErrorBoundary extends Component {
state: State = { hasError: false }
static getDerivedStateFromError(): State {
return { hasError: true }
}
render() {
if (this.state.hasError) {
return this.props.fallback
}
return this.props.children
}
}
```
#### Performance Optimization
**Code Splitting**
```tsx
import dynamic from "next/dynamic"
const DataTable = dynamic(() => import("@/components/data-table"), {
loading: () => ,
ssr: false,
})
```
**Image Optimization**
```tsx
import Image from "next/image"
```
**Memoization**
```tsx
import { useMemo } from "react"
const sortedWrestlers = useMemo(() => {
return wrestlers
.filter(w => w.isActive)
.sort((a, b) => a.lastName.localeCompare(b.lastName))
}, [wrestlers])
```
#### Testing Guidelines
**Component Testing**
```tsx
import { render, screen, fireEvent } from "@testing-library/react"
import { describe, it, expect, vi } from "vitest"
import { WrestlerCard } from "./wrestler-card"
describe("WrestlerCard", () => {
it("renders wrestler name", () => {
render()
expect(screen.getByText("Max Mustermann")).toBeInTheDocument()
})
it("calls onEdit when edit button is clicked", async () => {
const onEdit = vi.fn()
render()
fireEvent.click(screen.getByRole("button", { name: /bearbeiten/i }))
expect(onEdit).toHaveBeenCalledWith(mockWrestler.id)
})
})
```
#### Accessibility (a11y)
- Use semantic HTML elements (`