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
1199 lines
36 KiB
Markdown
1199 lines
36 KiB
Markdown
# 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 (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>{title}</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Button disabled={loading}>
|
|
{loading ? "Loading..." : "Submit"}
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
```
|
|
|
|
### 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<typeof formSchema>
|
|
|
|
// 3. Define interface BEFORE component
|
|
interface WrestlerFormProps {
|
|
onSubmit: (values: FormValues) => Promise<void>
|
|
defaultValues?: Partial<FormValues>
|
|
}
|
|
|
|
// 4. Component with explicit prop types
|
|
export function WrestlerForm({ onSubmit, defaultValues }: WrestlerFormProps) {
|
|
const [isLoading, setIsLoading] = useState(false)
|
|
|
|
const form = useForm<FormValues>({
|
|
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 (
|
|
<Form {...form}>
|
|
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
|
|
<FormField
|
|
control={form.control}
|
|
name="firstName"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Vorname</FormLabel>
|
|
<FormControl>
|
|
<Input placeholder="Max" {...field} />
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
<Button type="submit" disabled={isLoading}>
|
|
{isLoading ? "Speichern..." : "Speichern"}
|
|
</Button>
|
|
</form>
|
|
</Form>
|
|
)
|
|
}
|
|
```
|
|
|
|
**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<string | null>(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<string, string>) {
|
|
return useQuery({
|
|
queryKey: ["wrestlers", filters],
|
|
queryFn: () => apiFetch<{ results: Wrestler[] }>("/wrestlers/", { params: filters }),
|
|
})
|
|
}
|
|
|
|
export function useCreateWrestler() {
|
|
const queryClient = useQueryClient()
|
|
|
|
return useMutation({
|
|
mutationFn: (data: CreateWrestlerInput) =>
|
|
apiFetch<Wrestler>("/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<AuthState>()(
|
|
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<typeof schema>
|
|
|
|
export function LoginForm() {
|
|
const form = useForm<FormData>({
|
|
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 <WrestlerList wrestlers={data.results} />
|
|
}
|
|
```
|
|
|
|
**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 <WrestlerListSkeleton />
|
|
if (error) return <div>Fehler beim Laden</div>
|
|
|
|
return (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{data?.results.map((wrestler) => (
|
|
<WrestlerCard key={wrestler.id} wrestler={wrestler} />
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
|
|
#### 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 `<PageSkeleton />` 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 (
|
|
<div className="border rounded-xl">
|
|
<TableSkeleton rows={5} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return <Table data={exercises} onEdit={onEdit} onDelete={onDelete} />
|
|
}
|
|
|
|
export default function ExercisesPage() {
|
|
const [filters, setFilters] = useState(DEFAULT_FILTERS)
|
|
const [totalCount, setTotalCount] = useState(0)
|
|
|
|
if (isLoading) return <PageSkeleton /> // initial load only
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
<FadeIn>Header + Add Button</FadeIn>
|
|
<FadeIn delay={0.05}>Filter Bar</FadeIn>
|
|
<FadeIn delay={0.1}>
|
|
<ExerciseTable filters={filters} ... />
|
|
</FadeIn>
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
|
|
**Key rules:**
|
|
- `isLoading` (initial page load) → `<PageSkeleton />` returned BEFORE the main layout
|
|
- `isTableLoading` (filter/pagination changes) → table shows inline skeleton, page layout stays
|
|
- NEVER `return <PageSkeleton />` 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<number | null>(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
|
|
<Modal
|
|
open={!!deleteId}
|
|
onOpenChange={(open) => !open && setDeleteId(null)}
|
|
title="[Resource] löschen"
|
|
description="Bist du sicher, dass du diesen Eintrag löschen möchtest?"
|
|
size="sm"
|
|
footer={
|
|
<>
|
|
<Button variant="outline" onClick={() => setDeleteId(null)} disabled={isDeleting}>
|
|
Abbrechen
|
|
</Button>
|
|
<Button variant="destructive" onClick={confirmDelete} disabled={isDeleting}>
|
|
{isDeleting ? "..." : "Löschen"}
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
<div />
|
|
</Modal>
|
|
```
|
|
|
|
**Delete button in table (calls handleDelete, not the API directly):**
|
|
```tsx
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => handleDelete(item.id)}
|
|
className="hover:bg-destructive/10 hover:text-destructive"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</Button>
|
|
```
|
|
|
|
**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 (`<div />`) — 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<Props, State> {
|
|
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: () => <TableSkeleton />,
|
|
ssr: false,
|
|
})
|
|
```
|
|
|
|
**Image Optimization**
|
|
```tsx
|
|
import Image from "next/image"
|
|
|
|
<Image
|
|
src={wrestler.photo}
|
|
alt={wrestler.full_name}
|
|
width={200}
|
|
height={200}
|
|
className="object-cover rounded-lg"
|
|
/>
|
|
```
|
|
|
|
**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(<WrestlerCard wrestler={{ id: "1", firstName: "Max", lastName: "Mustermann" }} />)
|
|
expect(screen.getByText("Max Mustermann")).toBeInTheDocument()
|
|
})
|
|
|
|
it("calls onEdit when edit button is clicked", async () => {
|
|
const onEdit = vi.fn()
|
|
render(<WrestlerCard wrestler={mockWrestler} onEdit={onEdit} />)
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: /bearbeiten/i }))
|
|
|
|
expect(onEdit).toHaveBeenCalledWith(mockWrestler.id)
|
|
})
|
|
})
|
|
```
|
|
|
|
#### Accessibility (a11y)
|
|
|
|
- Use semantic HTML elements (`<button>`, `<nav>`, `<main>`, `<article>`)
|
|
- Add `aria-label` for icon-only buttons
|
|
- Ensure color contrast ratio ≥ 4.5:1
|
|
- Support keyboard navigation (Tab, Enter, Escape)
|
|
- Use `aria-invalid` and `aria-describedby` for form errors
|
|
|
|
---
|
|
|
|
## API Integration
|
|
|
|
### API Client (lib/api.ts)
|
|
|
|
```typescript
|
|
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api/v1"
|
|
|
|
interface FetchOptions extends RequestInit {
|
|
token?: string
|
|
}
|
|
|
|
export async function apiFetch<T>(
|
|
endpoint: string,
|
|
options: FetchOptions = {}
|
|
): Promise<T> {
|
|
const { token, ...fetchOptions } = options
|
|
|
|
const isFormData = options.body instanceof FormData
|
|
|
|
const headers: HeadersInit = isFormData ? {
|
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
...options.headers,
|
|
} : {
|
|
"Content-Type": "application/json",
|
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
...options.headers,
|
|
}
|
|
|
|
const response = await fetch(`${API_URL}${endpoint}`, {
|
|
...fetchOptions,
|
|
headers,
|
|
})
|
|
|
|
if (response.status === 401) {
|
|
if (typeof window !== "undefined") {
|
|
localStorage.removeItem("auth-storage")
|
|
window.location.href = "/login"
|
|
}
|
|
throw new Error("Session expired")
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({}))
|
|
throw new Error(error.detail || `API Error: ${response.status}`)
|
|
}
|
|
|
|
if (response.status === 204) {
|
|
return {} as T
|
|
}
|
|
|
|
return response.json()
|
|
}
|
|
```
|
|
|
|
### Auth Store (lib/auth.ts)
|
|
|
|
```typescript
|
|
"use client"
|
|
|
|
import { create } from "zustand"
|
|
import { persist } from "zustand/middleware"
|
|
|
|
interface AuthState {
|
|
token: string | null
|
|
user: any | null
|
|
setAuth: (token: string, user: any) => void
|
|
logout: () => void
|
|
}
|
|
|
|
export const useAuth = create<AuthState>()(
|
|
persist(
|
|
(set) => ({
|
|
token: null,
|
|
user: null,
|
|
setAuth: (token, user) => set({ token, user }),
|
|
logout: () => set({ token: null, user: null }),
|
|
}),
|
|
{ name: "auth-storage" }
|
|
)
|
|
)
|
|
```
|
|
|
|
---
|
|
|
|
## Backend (Django)
|
|
|
|
### Code Style Guidelines
|
|
|
|
- Use Black for code formatting (line length: 88)
|
|
- Use isort for import sorting
|
|
- 4 spaces indentation
|
|
- Models: PascalCase
|
|
- Model fields: snake_case
|
|
- Methods: snake_case
|
|
- Boolean fields: prefix with `is_`, `has_`, `can_`
|
|
|
|
### Django Model Patterns
|
|
|
|
```python
|
|
class Wrestler(models.Model):
|
|
first_name = models.CharField(max_length=100)
|
|
last_name = models.CharField(max_length=100)
|
|
club = models.ForeignKey('clubs.Club', on_delete=models.CASCADE)
|
|
|
|
group = models.CharField(max_length=20, choices=[
|
|
('kids', 'Kids'),
|
|
('youth', 'Youth'),
|
|
('adults', 'Adults'),
|
|
])
|
|
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
is_active = models.BooleanField(default=True)
|
|
|
|
class Meta:
|
|
ordering = ['last_name', 'first_name']
|
|
indexes = [
|
|
models.Index(fields=['club', 'group']),
|
|
]
|
|
```
|
|
|
|
### Django/Python Development Guidelines
|
|
|
|
#### Core Principles
|
|
- **Django-First Approach**: Use Django's built-in features and tools wherever possible to leverage its full capabilities
|
|
- **Code Quality**: Prioritize readability and maintainability; follow Django's coding style guide (PEP 8 compliance)
|
|
- **Naming Conventions**: Use descriptive variable and function names; adhere to naming conventions (lowercase with underscores for functions and variables)
|
|
- **Modular Architecture**: Structure your project in a modular way using Django apps to promote reusability and separation of concerns
|
|
- **Performance Awareness**: Always consider scalability and performance implications in your design decisions
|
|
|
|
#### Project Structure
|
|
|
|
**Application Structure**
|
|
```
|
|
app_name/
|
|
├── migrations/ # Database migration files
|
|
├── admin.py # Django admin configuration
|
|
├── apps.py # App configuration
|
|
├── models.py # Database models
|
|
├── managers.py # Custom model managers
|
|
├── signals.py # Django signals
|
|
├── tasks.py # Celery tasks (if applicable)
|
|
└── __init__.py # Package initialization
|
|
```
|
|
|
|
**API Structure**
|
|
```
|
|
api/
|
|
└── v1/
|
|
├── app_name/
|
|
│ ├── urls.py # URL routing
|
|
│ ├── serializers.py # Data serialization
|
|
│ ├── views.py # API views
|
|
│ ├── permissions.py # Custom permissions
|
|
│ ├── filters.py # Custom filters
|
|
│ └── validators.py # Custom validators
|
|
└── urls.py # Main API URL configuration
|
|
```
|
|
|
|
**Core Structure**
|
|
```
|
|
core/
|
|
├── responses.py # Unified response structures
|
|
├── pagination.py # Custom pagination classes
|
|
├── permissions.py # Base permission classes
|
|
├── exceptions.py # Custom exception handlers
|
|
├── middleware.py # Custom middleware
|
|
├── logging.py # Structured logging utilities
|
|
└── validators.py # Reusable validators
|
|
```
|
|
|
|
**Configuration Structure**
|
|
```
|
|
config/
|
|
├── settings/
|
|
│ ├── base.py # Base settings
|
|
│ ├── development.py # Development settings
|
|
│ ├── staging.py # Staging settings
|
|
│ └── production.py # Production settings
|
|
├── urls.py # Main URL configuration
|
|
└── wsgi.py # WSGI configuration
|
|
```
|
|
|
|
#### Views and API Design
|
|
- **Use Class-Based Views**: Leverage Django's class-based views (CBVs) with DRF's APIViews
|
|
- **RESTful Design**: Follow RESTful principles strictly with proper HTTP methods and status codes
|
|
- **Keep Views Light**: Focus views on request handling; keep business logic in models, managers, and services
|
|
- **Consistent Response Format**: Use unified response structure for both success and error cases
|
|
|
|
#### Models and Database
|
|
- **ORM First**: Leverage Django's ORM for database interactions; avoid raw SQL queries unless necessary for performance
|
|
- **Business Logic in Models**: Keep business logic in models and custom managers
|
|
- **Query Optimization**: Use `select_related` and `prefetch_related` for related object fetching
|
|
- **Database Indexing**: Implement proper database indexing for frequently queried fields
|
|
- **Transactions**: Use `transaction.atomic()` for data consistency in critical operations
|
|
|
|
#### Serializers and Validation
|
|
- **DRF Serializers**: Use Django REST Framework serializers for data validation and serialization
|
|
- **Custom Validation**: Implement custom validators for complex business rules
|
|
- **Field-Level Validation**: Use serializer field validation for input sanitization
|
|
- **Nested Serializers**: Properly handle nested relationships with appropriate serializers
|
|
|
|
#### Authentication and Permissions
|
|
- **JWT Authentication**: Use `djangorestframework-simplejwt` for JWT token-based authentication
|
|
- **Custom Permissions**: Implement granular permission classes for different user roles
|
|
- **Security Best Practices**: Implement proper CSRF protection, CORS configuration, and input sanitization
|
|
|
|
#### URL Configuration
|
|
- **URL Patterns**: Use `urlpatterns` to define clean URL patterns with each `path()` mapping routes to views
|
|
- **Nested Routing**: Use `include()` for modular URL organization
|
|
- **API Versioning**: Implement proper API versioning strategy (URL-based versioning recommended)
|
|
|
|
#### Query Optimization
|
|
- **N+1 Problem Prevention**: Always use `select_related` and `prefetch_related` appropriately
|
|
- **Query Monitoring**: Monitor query counts and execution time in development
|
|
- **Database Connection Pooling**: Implement connection pooling for high-traffic applications
|
|
- **Caching Strategy**: Use Django's cache framework with Redis/Memcached for frequently accessed data
|
|
|
|
#### Response Optimization
|
|
- **Pagination**: Standardize pagination across all list endpoints
|
|
- **Field Selection**: Allow clients to specify required fields to reduce payload size
|
|
- **Compression**: Enable response compression for large payloads
|
|
|
|
#### Unified Error Responses
|
|
```json
|
|
{
|
|
"success": false,
|
|
"message": "Error description",
|
|
"errors": {
|
|
"field_name": ["Specific error details"]
|
|
},
|
|
"error_code": "SPECIFIC_ERROR_CODE"
|
|
}
|
|
```
|
|
|
|
#### Exception Handling
|
|
- **Custom Exception Handler**: Implement global exception handling for consistent error responses
|
|
- **Django Signals**: Use Django signals to decouple error handling and post-model activities
|
|
- **Proper HTTP Status Codes**: Use appropriate HTTP status codes (400, 401, 403, 404, 422, 500, etc.)
|
|
|
|
#### Logging Strategy
|
|
- **Structured Logging**: Implement structured logging for API monitoring and debugging
|
|
- **Request/Response Logging**: Log API calls with execution time, user info, and response status
|
|
- **Performance Monitoring**: Log slow queries and performance bottlenecks
|
|
|
|
---
|
|
|
|
## API Endpoints
|
|
|
|
```
|
|
# Auth
|
|
POST /api/v1/auth/login/ - Login (rate limited)
|
|
POST /api/v1/auth/register/ - Register (rate limited)
|
|
POST /api/v1/auth/refresh/ - Refresh token (rate limited)
|
|
GET /api/v1/auth/me/ - Current user
|
|
GET/PATCH /api/v1/auth/preferences/ - User preferences
|
|
|
|
# Clubs
|
|
GET /api/v1/clubs/ - List clubs
|
|
POST /api/v1/clubs/ - Create club
|
|
GET /api/v1/clubs/{id}/ - Get club
|
|
PATCH /api/v1/clubs/{id}/ - Update club
|
|
DELETE /api/v1/clubs/{id}/ - Delete club
|
|
|
|
# Wrestlers
|
|
GET /api/v1/wrestlers/ - List wrestlers
|
|
POST /api/v1/wrestlers/ - Create wrestler
|
|
GET /api/v1/wrestlers/{id}/ - Get wrestler
|
|
PATCH /api/v1/wrestlers/{id}/ - Update wrestler
|
|
DELETE /api/v1/wrestlers/{id}/ - Delete wrestler
|
|
GET /api/v1/wrestlers/available_for_training/ - List ALL wrestlers
|
|
|
|
# Trainers
|
|
GET /api/v1/trainers/ - List trainers
|
|
POST /api/v1/trainers/ - Create trainer
|
|
GET /api/v1/trainers/{id}/ - Get trainer
|
|
PATCH /api/v1/trainers/{id}/ - Update trainer
|
|
DELETE /api/v1/trainers/{id}/ - Delete trainer
|
|
GET /api/v1/trainers/available_for_training/ - List ALL trainers
|
|
|
|
# Exercises
|
|
GET /api/v1/exercises/ - List exercises
|
|
POST /api/v1/exercises/ - Create exercise
|
|
GET /api/v1/exercises/{id}/ - Get exercise
|
|
PATCH /api/v1/exercises/{id}/ - Update exercise
|
|
DELETE /api/v1/exercises/{id}/ - Delete exercise
|
|
|
|
# Locations
|
|
GET /api/v1/locations/ - List locations
|
|
POST /api/v1/locations/ - Create location
|
|
GET /api/v1/locations/{id}/ - Get location
|
|
PATCH /api/v1/locations/{id}/ - Update location
|
|
DELETE /api/v1/locations/{id}/ - Delete location
|
|
|
|
# Trainings
|
|
GET /api/v1/trainings/ - List trainings
|
|
POST /api/v1/trainings/ - Create training
|
|
GET /api/v1/trainings/{id}/ - Get training detail
|
|
PATCH /api/v1/trainings/{id}/ - Update training
|
|
DELETE /api/v1/trainings/{id}/ - Delete training
|
|
|
|
# Attendances
|
|
GET /api/v1/attendances/?training={id} - List attendances
|
|
POST /api/v1/attendances/ - Add wrestler to training
|
|
DELETE /api/v1/attendances/{id}/ - Remove wrestler
|
|
|
|
# Training Exercises
|
|
GET /api/v1/training-exercises/ - List training exercises
|
|
POST /api/v1/training-exercises/ - Add exercise to training
|
|
|
|
# Templates
|
|
GET /api/v1/templates/ - List templates
|
|
POST /api/v1/templates/ - Create template
|
|
GET /api/v1/templates/{id}/ - Get template
|
|
PATCH /api/v1/templates/{id}/ - Update template
|
|
DELETE /api/v1/templates/{id}/ - Delete template
|
|
|
|
# Template Exercises
|
|
GET /api/v1/template-exercises/ - List template exercises
|
|
POST /api/v1/template-exercises/ - Add exercise to template
|
|
|
|
# Homework (Legacy - Templates)
|
|
GET /api/v1/homework/ - List homework templates
|
|
POST /api/v1/homework/ - Create homework template
|
|
GET /api/v1/homework/{id}/ - Get homework
|
|
PATCH /api/v1/homework/{id}/ - Update homework
|
|
DELETE /api/v1/homework/{id}/ - Delete homework
|
|
POST /api/v1/homework/{id}/assign/ - Assign to wrestlers
|
|
|
|
# Homework Exercise Items
|
|
GET /api/v1/homework-exercise-items/ - List exercise items
|
|
POST /api/v1/homework-exercise-items/ - Add exercise to homework
|
|
|
|
# Homework Assignments (Legacy)
|
|
GET /api/v1/homework-assignments/ - List assignments
|
|
POST /api/v1/homework-assignments/ - Create assignment
|
|
GET /api/v1/homework-assignments/{id}/ - Get assignment
|
|
POST /api/v1/homework-assignments/{id}/complete-item/ - Mark exercise complete
|
|
|
|
# Training Homework Assignments (NEW - Primary)
|
|
GET /api/v1/homework/training-assignments/ - List training assignments
|
|
POST /api/v1/homework/training-assignments/ - Create assignment
|
|
GET /api/v1/homework/training-assignments/{id}/ - Get assignment
|
|
PATCH /api/v1/homework/training-assignments/{id}/ - Update assignment
|
|
DELETE /api/v1/homework/training-assignments/{id}/ - Delete assignment
|
|
POST /api/v1/homework/training-assignments/{id}/complete/ - Mark complete
|
|
POST /api/v1/homework/training-assignments/{id}/uncomplete/ - Mark uncomplete
|
|
```
|
|
|
|
---
|
|
|
|
## Backend Environment
|
|
|
|
### Backend (.env)
|
|
|
|
```env
|
|
SECRET_KEY=your-secret-key-here
|
|
DEBUG=True
|
|
ALLOWED_HOSTS=localhost,127.0.0.1,testserver
|
|
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
|
|
```
|
|
|
|
---
|
|
|
|
## Current Status Summary
|
|
|
|
### Backend (COMPLETED)
|
|
- Django project with 9 apps: auth_app, clubs, wrestlers, trainers, exercises, locations, templates, trainings, homework
|
|
- All models with proper fields, relationships, indexes
|
|
- DRF ViewSets with pagination, filtering, search
|
|
- JWT authentication with rate limiting
|
|
- File Upload Validation with FileExtensionValidator
|
|
- Django admin with django-unfold
|
|
- Seed data command
|
|
|
|
### Frontend (COMPLETED)
|
|
- Next.js 16 with App Router
|
|
- React with TypeScript
|
|
- Shadcn UI components
|
|
- Tailwind CSS
|
|
- All main features implemented
|
|
- Dashboard with stats
|
|
- Full CRUD for wrestlers, trainers, exercises, clubs, templates
|
|
- Trainings with Grid/List/Calendar views
|
|
- Training detail with attendance and homework assignment
|
|
- Homework overview page
|
|
- Reactive filter pattern: header + filter (stable) separate from table (reactive update)
|
|
|
|
### Known Issues
|
|
1. ~~**Wrestlers page**: FormData bug in handleSubmit (needs fix)~~ — Fixed
|
|
2. **Locations page**: Missing frontend page (backend exists)
|
|
3. ~~**Settings page**: Sidebar link exists but page missing~~ — Fixed
|
|
4. ~~**Flash bug on /exercises**: Full page flashed white when typing in search~~ — Fixed with reactive table pattern
|
|
5. ~~**Flash bug on /trainers**: Full page flashed when filter changed~~ — Fixed with reactive table pattern
|
|
|
|
---
|
|
|
|
## Security Notes
|
|
|
|
- Rate limiting on auth endpoints (5/minute)
|
|
- JWT token expiration handled with automatic redirect to login
|
|
- File uploads validated with FileExtensionValidator
|
|
- FormData automatically detected in apiFetch (no Content-Type header)
|
|
|
|
---
|
|
|
|
## Color Scheme
|
|
|
|
- Primary: `#1B1A55` (navy)
|
|
- Secondary: `#535C91` (blue)
|
|
- Accent: `#9290C3` (lavender)
|
|
- Background: `#070F2B` (dark sidebar)
|