# 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 (
( Vorname )} /> ) } ``` **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 Button Filter 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 (`