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,677 @@
|
||||
# Training Log 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:** Create Training Log page with 3 tabs (Log/Historie/Analyse) for recording and analyzing wrestler exercise performance.
|
||||
|
||||
**Architecture:** Backend provides CRUD + stats endpoints for TrainingLogEntry model. Frontend uses tabbed interface with async data fetching.
|
||||
|
||||
**Tech Stack:** Django REST Framework (backend), React/Next.js (frontend), Lucide icons
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### Backend
|
||||
- **Create:** `backend/training_log/__init__.py`
|
||||
- **Create:** `backend/training_log/apps.py`
|
||||
- **Create:** `backend/training_log/models.py` — TrainingLogEntry model
|
||||
- **Create:** `backend/training_log/serializers.py`
|
||||
- **Create:** `backend/training_log/views.py`
|
||||
- **Create:** `backend/training_log/urls.py`
|
||||
- **Modify:** `backend/wrestleDesk/settings.py` — add 'training_log' to INSTALLED_APPS
|
||||
- **Modify:** `backend/wrestleDesk/urls.py` — include training_log URLs
|
||||
|
||||
### Frontend
|
||||
- **Create:** `frontend/src/app/(dashboard)/training-log/page.tsx` — Main page with tabs
|
||||
- **Modify:** `frontend/src/lib/api.ts` — Add ITrainingLogEntry, ITrainingLogStats interfaces
|
||||
- **Modify:** `frontend/src/components/layout/sidebar.tsx` — Add Training Log nav link
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
### Task 1: Create Training Log Backend App
|
||||
|
||||
- [ ] **Step 1: Create training_log directory and files**
|
||||
|
||||
Create `backend/training_log/` with `__init__.py` and `apps.py`
|
||||
|
||||
- [ ] **Step 2: Add 'training_log' to INSTALLED_APPS in settings.py**
|
||||
|
||||
Add `'training_log'` to the INSTALLED_APPS list.
|
||||
|
||||
- [ ] **Step 3: Create TrainingLogEntry model**
|
||||
|
||||
```python
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
class TrainingLogEntry(models.Model):
|
||||
wrestler = models.ForeignKey('wrestlers.Wrestler', on_delete=models.CASCADE, related_name='training_logs')
|
||||
training = models.ForeignKey('trainings.Training', on_delete=models.SET_NULL, null=True, blank=True, related_name='training_logs')
|
||||
exercise = models.ForeignKey('exercises.Exercise', on_delete=models.CASCADE, related_name='training_logs')
|
||||
reps = models.PositiveIntegerField()
|
||||
sets = models.PositiveIntegerField(default=1)
|
||||
time_minutes = models.PositiveIntegerField(null=True, blank=True)
|
||||
weight_kg = models.DecimalField(null=True, blank=True, max_digits=5, decimal_places=2)
|
||||
rating = models.PositiveSmallIntegerField(choices=[(1,1),(2,2),(3,3),(4,4),(5,5)], default=3)
|
||||
notes = models.TextField(blank=True)
|
||||
logged_at = models.DateTimeField(default=timezone.now)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-logged_at']
|
||||
indexes = [
|
||||
models.Index(fields=['wrestler']),
|
||||
models.Index(fields=['exercise']),
|
||||
models.Index(fields=['logged_at']),
|
||||
models.Index(fields=['training']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.wrestler} - {self.exercise.name} ({self.reps}x{self.sets})"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Create serializers.py**
|
||||
|
||||
```python
|
||||
from rest_framework import serializers
|
||||
from .models import TrainingLogEntry
|
||||
|
||||
|
||||
class TrainingLogEntrySerializer(serializers.ModelSerializer):
|
||||
wrestler_name = serializers.CharField(source='wrestler.__str__', read_only=True)
|
||||
exercise_name = serializers.CharField(source='exercise.name', read_only=True)
|
||||
training_date = serializers.DateField(source='training.date', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = TrainingLogEntry
|
||||
fields = [
|
||||
'id', 'wrestler', 'wrestler_name', 'training', 'training_date',
|
||||
'exercise', 'exercise_name', 'reps', 'sets', 'time_minutes',
|
||||
'weight_kg', 'rating', 'notes', 'logged_at', 'created_at'
|
||||
]
|
||||
|
||||
|
||||
class TrainingLogStatsSerializer(serializers.Serializer):
|
||||
total_entries = serializers.IntegerField()
|
||||
unique_exercises = serializers.IntegerField()
|
||||
total_reps = serializers.IntegerField()
|
||||
avg_sets = serializers.FloatField()
|
||||
avg_rating = serializers.FloatField()
|
||||
this_week = serializers.IntegerField()
|
||||
top_exercises = serializers.ListField(child=serializers.DictField())
|
||||
progress = serializers.DictField()
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Create views.py**
|
||||
|
||||
```python
|
||||
from rest_framework import viewsets
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from django.db.models import Count, Avg, Sum, F
|
||||
from django.db.models.functions import Coalesce
|
||||
from datetime import datetime, timedelta
|
||||
from .models import TrainingLogEntry
|
||||
from .serializers import TrainingLogEntrySerializer
|
||||
from wrestlers.models import Wrestler
|
||||
|
||||
|
||||
class TrainingLogEntryViewSet(viewsets.ModelViewSet):
|
||||
permission_classes = [IsAuthenticated]
|
||||
serializer_class = TrainingLogEntrySerializer
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = TrainingLogEntry.objects.all()
|
||||
wrestler = self.request.query_params.get('wrestler')
|
||||
exercise = self.request.query_params.get('exercise')
|
||||
date_from = self.request.query_params.get('date_from')
|
||||
date_to = self.request.query_params.get('date_to')
|
||||
|
||||
if wrestler:
|
||||
queryset = queryset.filter(wrestler=wrestler)
|
||||
if exercise:
|
||||
queryset = queryset.filter(exercise=exercise)
|
||||
if date_from:
|
||||
queryset = queryset.filter(logged_at__date__gte=date_from)
|
||||
if date_to:
|
||||
queryset = queryset.filter(logged_at__date__lte=date_to)
|
||||
|
||||
return queryset.select_related('wrestler', 'exercise', 'training')
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def training_log_stats(request):
|
||||
wrestler_id = request.query_params.get('wrestler')
|
||||
today = datetime.now().date()
|
||||
week_start = today - timedelta(days=today.weekday())
|
||||
|
||||
queryset = TrainingLogEntry.objects.all()
|
||||
if wrestler_id:
|
||||
queryset = queryset.filter(wrestler=wrestler_id)
|
||||
|
||||
total_entries = queryset.count()
|
||||
unique_exercises = queryset.values('exercise').distinct().count()
|
||||
total_reps = queryset.aggregate(total=Coalesce(Sum(F('reps') * F('sets')), 0))['total'] or 0
|
||||
avg_sets = queryset.aggregate(avg=Avg('sets'))['avg'] or 0
|
||||
avg_rating = queryset.aggregate(avg=Avg('rating'))['avg'] or 0
|
||||
this_week = queryset.filter(logged_at__date__gte=week_start).count()
|
||||
|
||||
top_exercises = queryset.values('exercise__name').annotate(
|
||||
count=Count('id')
|
||||
).order_by('-count')[:5]
|
||||
|
||||
progress = {}
|
||||
exercises = queryset.values('exercise', 'exercise__name').distinct()
|
||||
for ex in exercises:
|
||||
ex_id = ex['exercise']
|
||||
entries = queryset.filter(exercise=ex_id).order_by('logged_at')
|
||||
if entries.count() >= 2:
|
||||
first_reps = entries.first().reps * entries.first().sets
|
||||
last_reps = entries.last().reps * entries.last().sets
|
||||
if first_reps > 0:
|
||||
change = ((last_reps - first_reps) / first_reps) * 100
|
||||
progress[ex['exercise__name']] = {
|
||||
'before': first_reps,
|
||||
'after': last_reps,
|
||||
'change_percent': round(change, 1)
|
||||
}
|
||||
|
||||
return Response({
|
||||
'total_entries': total_entries,
|
||||
'unique_exercises': unique_exercises,
|
||||
'total_reps': total_reps,
|
||||
'avg_sets': round(avg_sets, 1),
|
||||
'avg_rating': round(avg_rating, 1),
|
||||
'this_week': this_week,
|
||||
'top_exercises': [{'name': e['exercise__name'], 'count': e['count']} for e in top_exercises],
|
||||
'progress': progress,
|
||||
})
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def training_log_compare(request):
|
||||
wrestler1_id = request.query_params.get('wrestler1')
|
||||
wrestler2_id = request.query_params.get('wrestler2')
|
||||
|
||||
if not wrestler1_id or not wrestler2_id:
|
||||
return Response({'error': 'Both wrestler1 and wrestler2 required'}, status=400)
|
||||
|
||||
wrestler1 = Wrestler.objects.get(id=wrestler1_id)
|
||||
wrestler2 = Wrestler.objects.get(id=wrestler2_id)
|
||||
|
||||
entries1 = TrainingLogEntry.objects.filter(wrestler=wrestler1)
|
||||
entries2 = TrainingLogEntry.objects.filter(wrestler=wrestler2)
|
||||
|
||||
exercises1 = entries1.values('exercise', 'exercise__name').distinct()
|
||||
exercises2 = entries2.values('exercise', 'exercise__name').distinct()
|
||||
common_exercises = set(e['exercise'] for e in exercises1) & set(e['exercise'] for e in exercises2)
|
||||
|
||||
comparison = []
|
||||
for ex_id in common_exercises:
|
||||
ex_name = entries1.filter(exercise=ex_id).first().exercise.name
|
||||
avg1 = entries1.filter(exercise=ex_id).aggregate(avg=Avg(F('reps') * F('sets')))['avg'] or 0
|
||||
avg2 = entries2.filter(exercise=ex_id).aggregate(avg=Avg(F('reps') * F('sets')))['avg'] or 0
|
||||
comparison.append({
|
||||
'exercise': ex_name,
|
||||
'wrestler1_avg': round(avg1, 1),
|
||||
'wrestler2_avg': round(avg2, 1)
|
||||
})
|
||||
|
||||
return Response({
|
||||
'wrestler1': {'id': wrestler1.id, 'name': str(wrestler1)},
|
||||
'wrestler2': {'id': wrestler2.id, 'name': str(wrestler2)},
|
||||
'exercises': comparison
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Create urls.py**
|
||||
|
||||
```python
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .views import TrainingLogEntryViewSet, training_log_stats, training_log_compare
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'', TrainingLogEntryViewSet, basename='training-log')
|
||||
|
||||
urlpatterns = [
|
||||
path('stats/', training_log_stats, name='training-log-stats'),
|
||||
path('compare/', training_log_compare, name='training-log-compare'),
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Add URL to main urls.py**
|
||||
|
||||
Add to `backend/wrestleDesk/urls.py`:
|
||||
```python
|
||||
path('api/v1/training-log/', include('training_log.urls')),
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Create migration**
|
||||
|
||||
Run: `cd backend && python manage.py makemigrations training_log`
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Update Frontend API Types
|
||||
|
||||
- [ ] **Step 1: Add interfaces to frontend/src/lib/api.ts**
|
||||
|
||||
```typescript
|
||||
export interface ITrainingLogEntry {
|
||||
id: number
|
||||
wrestler: number
|
||||
wrestler_name: string
|
||||
training: number | null
|
||||
training_date: string | null
|
||||
exercise: number
|
||||
exercise_name: string
|
||||
reps: number
|
||||
sets: number
|
||||
time_minutes: number | null
|
||||
weight_kg: number | null
|
||||
rating: number
|
||||
notes: string
|
||||
logged_at: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface ITrainingLogStats {
|
||||
total_entries: number
|
||||
unique_exercises: number
|
||||
total_reps: number
|
||||
avg_sets: number
|
||||
avg_rating: number
|
||||
this_week: number
|
||||
top_exercises: { name: string; count: number }[]
|
||||
progress: Record<string, { before: number; after: number; change_percent: number }>
|
||||
}
|
||||
|
||||
export interface ITrainingLogCompare {
|
||||
wrestler1: { id: number; name: string }
|
||||
wrestler2: { id: number; name: string }
|
||||
exercises: { exercise: string; wrestler1_avg: number; wrestler2_avg: number }[]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Create Training Log Page
|
||||
|
||||
- [ ] **Step 1: Create frontend/src/app/(dashboard)/training-log/page.tsx**
|
||||
|
||||
Full page component with:
|
||||
- Tab state (log | histrie | analyse)
|
||||
- Log tab: Form with wrestler, training, exercise, reps, sets, time, weight, rating, notes
|
||||
- Historie tab: Filter bar + table with entries
|
||||
- Analyse tab: Stats summary, progress bars, wrestler comparison
|
||||
|
||||
```typescript
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useAuth } from "@/lib/auth"
|
||||
import { apiFetch, ITrainingLogEntry, ITrainingLogStats, ITrainingLogCompare, IWrestler, IExercise, ITraining, PaginatedResponse } from "@/lib/api"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { PageSkeleton } from "@/components/ui/skeletons"
|
||||
import { FadeIn } from "@/components/ui/animations"
|
||||
import { ClipboardList, History, BarChart3, Plus, Star, Loader2 } from "lucide-react"
|
||||
|
||||
type TabType = "log" | "historie" | "analyse"
|
||||
|
||||
export default function TrainingLogPage() {
|
||||
const { token } = useAuth()
|
||||
const [activeTab, setActiveTab] = useState<TabType>("log")
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
// Data states
|
||||
const [entries, setEntries] = useState<ITrainingLogEntry[]>([])
|
||||
const [stats, setStats] = useState<ITrainingLogStats | null>(null)
|
||||
const [wrestlers, setWrestlers] = useState<IWrestler[]>([])
|
||||
const [exercises, setExercises] = useState<IExercise[]>([])
|
||||
const [trainings, setTrainings] = useState<ITraining[]>([])
|
||||
|
||||
// Filter states
|
||||
const [filterWrestler, setFilterWrestler] = useState<string>("")
|
||||
const [filterExercise, setFilterExercise] = useState<string>("")
|
||||
|
||||
// Form state
|
||||
const [formData, setFormData] = useState({
|
||||
wrestler: "",
|
||||
training: "",
|
||||
exercise: "",
|
||||
reps: "",
|
||||
sets: "1",
|
||||
time_minutes: "",
|
||||
weight_kg: "",
|
||||
rating: "3",
|
||||
notes: ""
|
||||
})
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return
|
||||
fetchData()
|
||||
}, [token])
|
||||
|
||||
const fetchData = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const [entriesRes, wrestlersRes, exercisesRes, trainingsRes] = await Promise.all([
|
||||
apiFetch<PaginatedResponse<ITrainingLogEntry>>("/training-log/", { token }),
|
||||
apiFetch<PaginatedResponse<IWrestler>>("/wrestlers/?page_size=100", { token }),
|
||||
apiFetch<PaginatedResponse<IExercise>>("/exercises/?page_size=100", { token }),
|
||||
apiFetch<PaginatedResponse<ITraining>>("/trainings/?page_size=100", { token }),
|
||||
])
|
||||
setEntries(entriesRes.results || [])
|
||||
setWrestlers(wrestlersRes.results || [])
|
||||
setExercises(exercisesRes.results || [])
|
||||
setTrainings(trainingsRes.results || [])
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch data:", err)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!formData.wrestler || !formData.exercise || !formData.reps) return
|
||||
|
||||
setIsSaving(true)
|
||||
try {
|
||||
await apiFetch("/training-log/", {
|
||||
method: "POST",
|
||||
token: token!,
|
||||
body: JSON.stringify({
|
||||
wrestler: parseInt(formData.wrestler),
|
||||
training: formData.training ? parseInt(formData.training) : null,
|
||||
exercise: parseInt(formData.exercise),
|
||||
reps: parseInt(formData.reps),
|
||||
sets: parseInt(formData.sets) || 1,
|
||||
time_minutes: formData.time_minutes ? parseInt(formData.time_minutes) : null,
|
||||
weight_kg: formData.weight_kg ? parseFloat(formData.weight_kg) : null,
|
||||
rating: parseInt(formData.rating),
|
||||
notes: formData.notes,
|
||||
}),
|
||||
})
|
||||
toast.success("Eintrag gespeichert")
|
||||
setFormData({ wrestler: "", training: "", exercise: "", reps: "", sets: "1", time_minutes: "", weight_kg: "", rating: "3", notes: "" })
|
||||
fetchData()
|
||||
} catch {
|
||||
toast.error("Fehler beim Speichern")
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const filteredEntries = entries.filter(e => {
|
||||
if (filterWrestler && e.wrestler !== parseInt(filterWrestler)) return false
|
||||
if (filterExercise && e.exercise !== parseInt(filterExercise)) return false
|
||||
return true
|
||||
})
|
||||
|
||||
const tabs = [
|
||||
{ id: "log" as TabType, label: "Log", icon: ClipboardList },
|
||||
{ id: "historie" as TabType, label: "Historie", icon: History },
|
||||
{ id: "analyse" as TabType, label: "Analyse", icon: BarChart3 },
|
||||
]
|
||||
|
||||
if (isLoading) return <PageSkeleton />
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<FadeIn>
|
||||
<h1 className="text-2xl font-bold">Training Log</h1>
|
||||
</FadeIn>
|
||||
|
||||
{/* Tabs */}
|
||||
<FadeIn delay={0.05}>
|
||||
<div className="flex gap-2 border-b pb-2">
|
||||
{tabs.map(tab => (
|
||||
<Button
|
||||
key={tab.id}
|
||||
variant={activeTab === tab.id ? "default" : "ghost"}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className="gap-2"
|
||||
>
|
||||
<tab.icon className="w-4 h-4" />
|
||||
{tab.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</FadeIn>
|
||||
|
||||
{/* Log Tab */}
|
||||
{activeTab === "log" && (
|
||||
<FadeIn delay={0.1}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Neuer Eintrag</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Ringer *</label>
|
||||
<Select value={formData.wrestler} onValueChange={v => setFormData({...formData, wrestler: v})}>
|
||||
<SelectTrigger><SelectValue placeholder="Ringer wählen" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{wrestlers.map(w => (
|
||||
<SelectItem key={w.id} value={String(w.id)}>{w.first_name} {w.last_name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Training</label>
|
||||
<Select value={formData.training} onValueChange={v => setFormData({...formData, training: v})}>
|
||||
<SelectTrigger><SelectValue placeholder="Training (optional)" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{trainings.map(t => (
|
||||
<SelectItem key={t.id} value={String(t.id)}>
|
||||
{new Date(t.date).toLocaleDateString("de-DE")}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Übung *</label>
|
||||
<Select value={formData.exercise} onValueChange={v => setFormData({...formData, exercise: v})}>
|
||||
<SelectTrigger><SelectValue placeholder="Übung wählen" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{exercises.map(e => (
|
||||
<SelectItem key={e.id} value={String(e.id)}>{e.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Reps *</label>
|
||||
<Input type="number" value={formData.reps} onChange={e => setFormData({...formData, reps: e.target.value})} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Sets</label>
|
||||
<Input type="number" value={formData.sets} onChange={e => setFormData({...formData, sets: e.target.value})} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Zeit (min)</label>
|
||||
<Input type="number" value={formData.time_minutes} onChange={e => setFormData({...formData, time_minutes: e.target.value})} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Gewicht (kg)</label>
|
||||
<Input type="number" step="0.5" value={formData.weight_kg} onChange={e => setFormData({...formData, weight_kg: e.target.value})} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Bewertung</label>
|
||||
<div className="flex gap-1">
|
||||
{[1,2,3,4,5].map(star => (
|
||||
<button key={star} type="button" onClick={() => setFormData({...formData, rating: String(star)})}>
|
||||
<Star className={`w-5 h-5 ${star <= parseInt(formData.rating) ? "fill-yellow-400 text-yellow-400" : "text-gray-300"}`} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="text-sm font-medium">Notizen</label>
|
||||
<Textarea value={formData.notes} onChange={e => setFormData({...formData, notes: e.target.value})} />
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<Button type="submit" disabled={isSaving || !formData.wrestler || !formData.exercise || !formData.reps}>
|
||||
{isSaving && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||
Speichern
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FadeIn>
|
||||
)}
|
||||
|
||||
{/* Historie Tab */}
|
||||
{activeTab === "historie" && (
|
||||
<FadeIn delay={0.1}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Eintragsverlauf</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex gap-4 flex-wrap">
|
||||
<Select value={filterWrestler} onValueChange={setFilterWrestler}>
|
||||
<SelectTrigger className="w-[160px]"><SelectValue placeholder="Ringer" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">Alle</SelectItem>
|
||||
{wrestlers.map(w => (
|
||||
<SelectItem key={w.id} value={String(w.id)}>{w.first_name} {w.last_name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={filterExercise} onValueChange={setFilterExercise}>
|
||||
<SelectTrigger className="w-[160px]"><SelectValue placeholder="Übung" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">Alle</SelectItem>
|
||||
{exercises.map(e => (
|
||||
<SelectItem key={e.id} value={String(e.id)}>{e.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted">
|
||||
<tr>
|
||||
<th className="p-2 text-left">Datum</th>
|
||||
<th className="p-2 text-left">Ringer</th>
|
||||
<th className="p-2 text-left">Übung</th>
|
||||
<th className="p-2 text-left">Reps×Sets</th>
|
||||
<th className="p-2 text-left">Zeit</th>
|
||||
<th className="p-2 text-left">Gewicht</th>
|
||||
<th className="p-2 text-left">Bewertung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredEntries.map(entry => (
|
||||
<tr key={entry.id} className="border-t">
|
||||
<td className="p-2">{new Date(entry.logged_at).toLocaleDateString("de-DE")}</td>
|
||||
<td className="p-2">{entry.wrestler_name}</td>
|
||||
<td className="p-2">{entry.exercise_name}</td>
|
||||
<td className="p-2">{entry.reps}×{entry.sets}</td>
|
||||
<td className="p-2">{entry.time_minutes ? `${entry.time_minutes}min` : "-"}</td>
|
||||
<td className="p-2">{entry.weight_kg ? `${entry.weight_kg}kg` : "-"}</td>
|
||||
<td className="p-2">
|
||||
<div className="flex">
|
||||
{[1,2,3,4,5].map(s => (
|
||||
<Star key={s} className={`w-3 h-3 ${s <= entry.rating ? "fill-yellow-400 text-yellow-400" : "text-gray-300"}`} />
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FadeIn>
|
||||
)}
|
||||
|
||||
{/* Analyse Tab */}
|
||||
{activeTab === "analyse" && stats && (
|
||||
<FadeIn delay={0.1}>
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-base">Zusammenfassung</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
<div className="flex justify-between"><span>Gesamt:</span><span className="font-medium">{stats.total_entries}</span></div>
|
||||
<div className="flex justify-between"><span>Übungen:</span><span className="font-medium">{stats.unique_exercises}</span></div>
|
||||
<div className="flex justify-between"><span>Wiederholungen:</span><span className="font-medium">{stats.total_reps}</span></div>
|
||||
<div className="flex justify-between"><span>Ø Sätze:</span><span className="font-medium">{stats.avg_sets}</span></div>
|
||||
<div className="flex justify-between"><span>Ø Bewertung:</span><span className="font-medium">{stats.avg_rating}/5</span></div>
|
||||
<div className="flex justify-between"><span>Diese Woche:</span><span className="font-medium">{stats.this_week}</span></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-base">Top Übungen</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{stats.top_exercises.map((ex, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<span className="text-sm w-4">{i+1}.</span>
|
||||
<span className="flex-1 text-sm">{ex.name}</span>
|
||||
<Badge variant="secondary">{ex.count}x</Badge>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</FadeIn>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add navigation link to sidebar**
|
||||
|
||||
Add to `frontend/src/components/layout/sidebar.tsx`:
|
||||
```typescript
|
||||
{/* Training Log */}
|
||||
<SidebarItem href="/training-log" icon={ClipboardList} label="Training Log" />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### Backend
|
||||
- Run: `cd backend && python manage.py check`
|
||||
- Run: `python manage.py migrate`
|
||||
- Test endpoint: `curl -H "Authorization: Bearer <token>" http://localhost:8000/api/v1/training-log/`
|
||||
|
||||
### Frontend
|
||||
- Run: `cd frontend && npm run lint`
|
||||
- Visit: http://localhost:3000/training-log
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
- Weight input uses `step="0.5"` for decimal support
|
||||
- Training dropdown shows all trainings (could filter to past only)
|
||||
- Rating displayed as filled/unfilled stars
|
||||
- Entry form resets after successful submission
|
||||
- Toast notifications require importing "sonner" toast
|
||||
Reference in New Issue
Block a user