diff --git a/backend/time_tracking/serializers.py b/backend/time_tracking/serializers.py index 70bcb6b..90b5823 100644 --- a/backend/time_tracking/serializers.py +++ b/backend/time_tracking/serializers.py @@ -19,6 +19,8 @@ class TimeEntryCreateSerializer(serializers.ModelSerializer): def validate(self, attrs): if not attrs.get('start_time') and not attrs.get('end_time'): raise serializers.ValidationError('Une heure d’entrée ou de sortie est requise.') + if attrs.get('start_time') and attrs.get('end_time') and attrs['end_time'] <= attrs['start_time']: + raise serializers.ValidationError('L’heure de sortie doit être après l’heure d’entrée.') return attrs class TimeEntryUpdateSerializer(serializers.ModelSerializer): @@ -29,6 +31,8 @@ class TimeEntryUpdateSerializer(serializers.ModelSerializer): def validate(self, attrs): if not attrs.get('start_time') and not attrs.get('end_time'): raise serializers.ValidationError('Une heure d’entrée ou de sortie est requise.') + if attrs.get('start_time') and attrs.get('end_time') and attrs['end_time'] <= attrs['start_time']: + raise serializers.ValidationError('L’heure de sortie doit être après l’heure d’entrée.') return attrs class WeeklySummarySerializer(serializers.Serializer): diff --git a/backend/time_tracking/views.py b/backend/time_tracking/views.py index c25383d..ea1e5a9 100644 --- a/backend/time_tracking/views.py +++ b/backend/time_tracking/views.py @@ -6,6 +6,7 @@ from django.contrib.auth.models import User from django.db import transaction from django.utils import timezone from datetime import datetime, date, timedelta +from datetime import time as datetime_time from decouple import config from .models import TimeEntry from .serializers import ( @@ -40,8 +41,8 @@ class TimeEntryScanView(APIView): code = str(request.data.get('code') or '').strip().strip('"\'') if '=' in code: code = code.split('=', 1)[1].strip().strip('"\'') - enter_code = config('SECRET_POINTAGE_ENTER', default='').strip() - exit_code = config('SECRET_POINTAGE_EXIT', default='').strip() + enter_code = config('SECRET_POINTAGE_ENTER', default='').strip().strip('"\'') + exit_code = config('SECRET_POINTAGE_EXIT', default='').strip().strip('"\'') if code == enter_code: action = 'enter' elif code == exit_code: @@ -50,6 +51,16 @@ class TimeEntryScanView(APIView): return Response({'error': 'QR code de pointage invalide.'}, status=status.HTTP_400_BAD_REQUEST) now = timezone.localtime() + requested_time = request.data.get('time') + if requested_time: + try: + selected_time = datetime_time.fromisoformat(str(requested_time)[:5]) + except ValueError: + return Response({'error': 'Heure invalide.'}, status=status.HTTP_400_BAD_REQUEST) + else: + selected_time = now.time().replace(second=0, microsecond=0) + resolution = request.data.get('resolution') + with transaction.atomic(): open_entry = TimeEntry.objects.select_for_update().filter( user=request.user, @@ -57,20 +68,64 @@ class TimeEntryScanView(APIView): start_time__isnull=False, end_time__isnull=True, ).order_by('-start_time').first() + open_exit = TimeEntry.objects.select_for_update().filter( + user=request.user, + date=now.date(), + start_time__isnull=True, + end_time__isnull=False, + ).order_by('-end_time').first() if action == 'enter': if open_entry: - return Response({'error': 'Une entrée est déjà ouverte pour aujourd’hui.'}, status=status.HTTP_400_BAD_REQUEST) - entry = TimeEntry.objects.create(user=request.user, date=now.date(), start_time=now.time()) + if resolution == 'cancel_entry': + open_entry.delete() + return Response({'action': action, 'message': 'Entrée annulée.'}) + if resolution == 'complete_entry': + if selected_time <= open_entry.start_time: + return Response({'error': 'L’heure de sortie doit être après l’entrée.'}, status=status.HTTP_400_BAD_REQUEST) + open_entry.end_time = selected_time + open_entry.save(update_fields=['end_time', 'updated_at']) + return Response({'action': action, 'message': 'Sortie enregistrée pour l’entrée précédente.', 'entry': TimeEntrySerializer(open_entry).data}) + return Response({'error': 'Une entrée est déjà ouverte.', 'conflict': 'open_entry', 'options': ['cancel_entry', 'complete_entry']}, status=status.HTTP_409_CONFLICT) + if open_exit: + if resolution == 'cancel_exit': + open_exit.delete() + return Response({'action': action, 'message': 'Sortie annulée.'}) + if resolution == 'complete_exit': + open_exit.start_time = selected_time + open_exit.save(update_fields=['start_time', 'updated_at']) + return Response({'action': action, 'message': 'Entrée enregistrée pour la sortie précédente.', 'entry': TimeEntrySerializer(open_exit).data}) + return Response({'error': 'Une sortie existe déjà.', 'conflict': 'open_exit', 'options': ['cancel_exit', 'complete_exit']}, status=status.HTTP_409_CONFLICT) + entry = TimeEntry.objects.create(user=request.user, date=now.date(), start_time=selected_time) message = 'Entrée enregistrée.' elif open_entry: - open_entry.end_time = now.time() + open_entry.end_time = selected_time open_entry.save(update_fields=['end_time', 'updated_at']) entry = open_entry message = 'Sortie enregistrée.' + elif open_exit: + if resolution == 'cancel_exit': + open_exit.delete() + return Response({'action': action, 'message': 'Sortie annulée.'}) + if resolution == 'complete_exit': + if selected_time >= open_exit.end_time: + return Response({'error': 'L’heure d’entrée doit être avant la sortie.'}, status=status.HTTP_400_BAD_REQUEST) + open_exit.start_time = selected_time + open_exit.save(update_fields=['start_time', 'updated_at']) + return Response({'action': action, 'message': 'Entrée enregistrée pour la sortie précédente.', 'entry': TimeEntrySerializer(open_exit).data}) + return Response({'error': 'Une sortie existe déjà.', 'conflict': 'open_exit', 'options': ['cancel_exit', 'complete_exit']}, status=status.HTTP_409_CONFLICT) else: - entry = TimeEntry.objects.create(user=request.user, date=now.date(), end_time=now.time()) - message = 'Sortie enregistrée sans entrée correspondante.' + previous_open_entry = TimeEntry.objects.select_for_update().filter( + user=request.user, + start_time__isnull=False, + end_time__isnull=True, + ).exclude(date=now.date()).order_by('-date', '-start_time').first() + if previous_open_entry: + entry = TimeEntry.objects.create(user=request.user, date=now.date(), end_time=selected_time) + message = 'Sortie enregistrée. L’entrée est restée sur la journée précédente.' + else: + entry = TimeEntry.objects.create(user=request.user, date=now.date(), end_time=selected_time) + message = 'Sortie enregistrée sans entrée correspondante.' return Response({'action': action, 'message': message, 'entry': TimeEntrySerializer(entry).data}) diff --git a/frontend/src/api/time.js b/frontend/src/api/time.js index 206bec3..a77e789 100644 --- a/frontend/src/api/time.js +++ b/frontend/src/api/time.js @@ -21,12 +21,15 @@ export const timeApi = { } }, - scanTimeEntry: async (code) => { + scanTimeEntry: async (code, options = {}) => { try { - const response = await api.post('/time/entries/scan/', { code }); + const response = await api.post('/time/entries/scan/', { code, ...options }); return response.data; } catch (error) { - throw new Error(error.response?.data?.error || `Erreur lors du pointage: ${error.message}`); + const scanError = new Error(error.response?.data?.error || `Erreur lors du pointage: ${error.message}`); + scanError.conflict = error.response?.data?.conflict; + scanError.code = error.response?.status; + throw scanError; } }, diff --git a/frontend/src/pages/TimeClockPage.css b/frontend/src/pages/TimeClockPage.css index 759d96a..5d761bd 100644 --- a/frontend/src/pages/TimeClockPage.css +++ b/frontend/src/pages/TimeClockPage.css @@ -163,4 +163,23 @@ th { margin-top: 20px; padding: 15px; background-color: #e9ecef; +} + +.scan-conflict { + display: grid; + gap: 10px; + margin-top: 16px; + padding: 16px; + background: #fff8ed; + border: 1px solid #e7c58b; + border-radius: var(--radius-sm); +} + +.scan-conflict p { + margin: 0; + color: var(--text-muted); +} + +.scan-conflict input { + width: 150px; } \ No newline at end of file diff --git a/frontend/src/pages/TimeClockPage.jsx b/frontend/src/pages/TimeClockPage.jsx index c638d31..d3d6e95 100644 --- a/frontend/src/pages/TimeClockPage.jsx +++ b/frontend/src/pages/TimeClockPage.jsx @@ -13,12 +13,15 @@ const TimeClockPage = () => { const [isScanning, setIsScanning] = useState(false); const [scanResult, setScanResult] = useState(''); const [scanMessage, setScanMessage] = useState(''); + const [scanConflict, setScanConflict] = useState(null); + const [conflictTime, setConflictTime] = useState(new Date().toTimeString().slice(0, 5)); const [error, setError] = useState(''); const [currentTime, setCurrentTime] = useState(new Date()); const [timeEntries, setTimeEntries] = useState([]); const [loading, setLoading] = useState(true); const [newEntry, setNewEntry] = useState({ date: new Date().toISOString().split('T')[0], + end_date: new Date().toISOString().split('T')[0], start_time: '', end_time: '' }); @@ -64,9 +67,21 @@ const TimeClockPage = () => { } try { - await timeApi.createTimeEntry(newEntry); + if (newEntry.date === newEntry.end_date) { + await timeApi.createTimeEntry({ + date: newEntry.date, + start_time: newEntry.start_time, + end_time: newEntry.end_time, + }); + } else { + await Promise.all([ + timeApi.createTimeEntry({ date: newEntry.date, start_time: newEntry.start_time }), + timeApi.createTimeEntry({ date: newEntry.end_date, end_time: newEntry.end_time }), + ]); + } setNewEntry({ date: new Date().toISOString().split('T')[0], + end_date: new Date().toISOString().split('T')[0], start_time: '', end_time: '' }); @@ -144,6 +159,7 @@ const TimeClockPage = () => { const handleQrResult = async (code) => { setScanResult(code); + setScanConflict(null); stopScanner(); try { @@ -151,7 +167,27 @@ const TimeClockPage = () => { setScanMessage(response.message); await loadTimeEntries(); } catch (scanError) { - setError(scanError.message); + if (scanError.code === 409 && scanError.conflict) { + setScanConflict({ code, type: scanError.conflict, message: scanError.message }); + } else { + setError(scanError.message); + } + } + }; + + const resolveScanConflict = async (resolution) => { + if (!scanConflict) return; + setError(''); + try { + const response = await timeApi.scanTimeEntry(scanConflict.code, { + resolution, + time: conflictTime, + }); + setScanMessage(response.message); + setScanConflict(null); + await loadTimeEntries(); + } catch (resolutionError) { + setError(resolutionError.message); } }; @@ -209,6 +245,7 @@ const TimeClockPage = () => { setError(''); setScanResult(''); setScanMessage(''); + setScanConflict(null); if (!window.isSecureContext && window.location.hostname !== 'localhost' && window.location.hostname !== '127.0.0.1') { setError('La caméra nécessite une connexion HTTPS sur cette adresse réseau.'); @@ -272,6 +309,28 @@ const TimeClockPage = () => { {scanMessage &&
Choisissez quoi faire avec le pointage précédent :
+ + setConflictTime(event.target.value)} + /> +