diff --git a/backend/time_tracking/migrations/0003_timeentry_group_id.py b/backend/time_tracking/migrations/0003_timeentry_group_id.py new file mode 100644 index 0000000..53c81fd --- /dev/null +++ b/backend/time_tracking/migrations/0003_timeentry_group_id.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.17 on 2026-09-20 01:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('time_tracking', '0002_fix_user_foreign_key'), + ] + + operations = [ + migrations.AddField( + model_name='timeentry', + name='group_id', + field=models.UUIDField(blank=True, db_index=True, null=True), + ), + ] diff --git a/backend/time_tracking/models.py b/backend/time_tracking/models.py index 139d063..c19003e 100644 --- a/backend/time_tracking/models.py +++ b/backend/time_tracking/models.py @@ -1,6 +1,8 @@ +import uuid +from datetime import datetime + from django.conf import settings from django.db import models -from datetime import datetime class TimeEntry(models.Model): @@ -10,29 +12,92 @@ class TimeEntry(models.Model): related_name='time_entries', ) + # Même group_id = même pointage logique réparti sur plusieurs jours. + # NULL pour les pointages classiques sur une seule journée. + group_id = models.UUIDField( + null=True, + blank=True, + db_index=True, + ) + date = models.DateField() - start_time = models.TimeField(null=True, blank=True) - end_time = models.TimeField(null=True, blank=True) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - + + start_time = models.TimeField( + null=True, + blank=True, + ) + + end_time = models.TimeField( + null=True, + blank=True, + ) + + end_time = models.TimeField( + null=True, + blank=True, + ) + + created_at = models.DateTimeField( + auto_now_add=True, + ) + + updated_at = models.DateTimeField( + auto_now=True, + ) + class Meta: ordering = ['-date', '-start_time'] - unique_together = ['user', 'date', 'start_time'] - + + unique_together = [ + 'user', + 'date', + 'start_time', + ] + def __str__(self): - return f"{self.user.username} - {self.date} ({self.start_time} to {self.end_time})" + return ( + f"{self.user.username} - " + f"{self.date} " + f"({self.start_time} to {self.end_time})" + ) @property def duration(self): - """Calculate duration in hours""" - if self.start_time and self.end_time: - start = datetime.combine(self.date, self.start_time) - end = datetime.combine(self.date, self.end_time) - return (end - start).total_seconds() / 3600 - return None + if not self.start_time or not self.end_time: + return None + + end_date = self.end_date or self.date + + start = datetime.combine( + self.date, + self.start_time, + ) + + end = datetime.combine( + end_date, + self.end_time, + ) + + seconds = ( + end - start + ).total_seconds() + + if seconds < 0: + return None + + return seconds / 3600 @property def day_of_week(self): - """Get day of week as string""" return self.date.strftime('%A') + + @property + def is_complete(self): + return bool( + self.start_time + and self.end_time + ) + + @property + def is_incomplete(self): + return not self.is_complete \ No newline at end of file diff --git a/backend/time_tracking/serializers.py b/backend/time_tracking/serializers.py index 0394b67..af03846 100644 --- a/backend/time_tracking/serializers.py +++ b/backend/time_tracking/serializers.py @@ -1,5 +1,9 @@ -from rest_framework import serializers +from datetime import time as datetime_time +from uuid import uuid4 + from django.contrib.auth import get_user_model +from django.db import transaction +from rest_framework import serializers from .models import TimeEntry @@ -10,17 +14,27 @@ User = get_user_model() class UserSerializer(serializers.ModelSerializer): class Meta: model = User - fields = ['id', 'username', 'first_name', 'last_name'] + fields = [ + 'id', + 'username', + 'first_name', + 'last_name', + ] class TimeEntrySerializer(serializers.ModelSerializer): user = UserSerializer(read_only=True) + is_complete = serializers.ReadOnlyField() + is_incomplete = serializers.ReadOnlyField() + class Meta: model = TimeEntry + fields = [ 'id', 'user', + 'group_id', 'date', 'start_time', 'end_time', @@ -28,7 +42,10 @@ class TimeEntrySerializer(serializers.ModelSerializer): 'updated_at', 'duration', 'day_of_week', + 'is_complete', + 'is_incomplete', ] + read_only_fields = [ 'id', 'user', @@ -36,6 +53,8 @@ class TimeEntrySerializer(serializers.ModelSerializer): 'updated_at', 'duration', 'day_of_week', + 'is_complete', + 'is_incomplete', ] def create(self, validated_data): @@ -55,46 +74,171 @@ class TimeEntryCreateSerializer(serializers.ModelSerializer): required=False, ) + end_date = serializers.DateField( + write_only=True, + required=False, + ) + class Meta: model = TimeEntry - fields = ['user_id', 'date', 'start_time', 'end_time'] + + fields = [ + 'user_id', + 'date', + 'end_date', + 'start_time', + 'end_time', + ] def validate(self, attrs): - if not attrs.get('start_time') and not attrs.get('end_time'): + start_time = attrs.get('start_time') + end_time = attrs.get('end_time') + date = attrs.get('date') + end_date = attrs.get('end_date') or date + + if not start_time and not end_time: raise serializers.ValidationError( 'Une heure d’entrée ou de sortie est requise.' ) + if end_date < date: + raise serializers.ValidationError( + 'La date de sortie doit être postérieure ou égale à la date d’entrée.' + ) + + # Une entrée et une sortie le même jour doivent être cohérentes. if ( - attrs.get('start_time') - and attrs.get('end_time') - and attrs['end_time'] <= attrs['start_time'] + date == end_date + and start_time + and end_time + and end_time <= start_time ): raise serializers.ValidationError( 'L’heure de sortie doit être après l’entrée.' ) + # Pour un pointage sur plusieurs jours, les deux extrémités + # doivent être connues. + if ( + date != end_date + and ( + not start_time + or not end_time + ) + ): + raise serializers.ValidationError( + 'Un pointage sur plusieurs jours nécessite une heure d’entrée et une heure de sortie.' + ) + + attrs['end_date'] = end_date + return attrs + def create(self, validated_data): + user = validated_data.pop('user', None) + end_date = validated_data.pop('end_date') + + if user is None: + user = self.context['request'].user + + start_date = validated_data['date'] + start_time = validated_data.get('start_time') + end_time = validated_data.get('end_time') + + # Pointage classique sur une seule journée. + if start_date == end_date: + return TimeEntry.objects.create( + user=user, + date=start_date, + start_time=start_time, + end_time=end_time, + ) + + # Pointage multi-jours : + # + # Exemple : + # 20/09 14:00 -> 22/09 10:00 + # + # 20/09 14:00 -> 23:59:59 + # 21/09 00:00 -> 23:59:59 + # 22/09 00:00 -> 10:00 + group_id = uuid4() + + created_entries = [] + + current_date = start_date + + with transaction.atomic(): + while current_date <= end_date: + if current_date == start_date: + entry_start = start_time + entry_end = datetime_time( + 23, + 59, + 59, + ) + + elif current_date == end_date: + entry_start = datetime_time.min + entry_end = end_time + + else: + entry_start = datetime_time.min + entry_end = datetime_time( + 23, + 59, + 59, + ) + + created_entries.append( + TimeEntry.objects.create( + user=user, + group_id=group_id, + date=current_date, + start_time=entry_start, + end_time=entry_end, + ) + ) + + from datetime import timedelta + + current_date += timedelta(days=1) + + return created_entries[0] + class TimeEntryUpdateSerializer(serializers.ModelSerializer): class Meta: model = TimeEntry - fields = ['start_time', 'end_time'] + + fields = [ + 'start_time', + 'end_time', + ] def validate(self, attrs): - if not attrs.get('start_time') and not attrs.get('end_time'): + start_time = attrs.get( + 'start_time', + self.instance.start_time, + ) + + end_time = attrs.get( + 'end_time', + self.instance.end_time, + ) + + if not start_time and not 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'] + start_time + and end_time + and end_time <= start_time ): raise serializers.ValidationError( - 'L’heure de sortie doit être après l’heure d’entrée.' + 'L’heure de sortie doit être après l’entrée.' ) return attrs @@ -104,4 +248,6 @@ class WeeklySummarySerializer(serializers.Serializer): user = serializers.CharField() username = serializers.CharField() total_hours = serializers.FloatField() - entries = serializers.ListField(child=serializers.DictField()) \ No newline at end of file + entries = serializers.ListField( + child=serializers.DictField(), + ) \ No newline at end of file diff --git a/backend/time_tracking/views.py b/backend/time_tracking/views.py index 026a50d..252865d 100644 --- a/backend/time_tracking/views.py +++ b/backend/time_tracking/views.py @@ -1,38 +1,49 @@ -from rest_framework import generics, status -from rest_framework.response import Response -from rest_framework.permissions import IsAuthenticated -from rest_framework.views import APIView -from django.conf import settings -from django.db import transaction -from django.utils import timezone from datetime import timedelta from datetime import time as datetime_time + from decouple import config +from django.conf import settings +from django.contrib.auth import get_user_model +from django.db import transaction +from django.utils import timezone +from rest_framework import generics, status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + from .models import TimeEntry from .serializers import ( - TimeEntrySerializer, - TimeEntryCreateSerializer, + TimeEntryCreateSerializer, + TimeEntrySerializer, TimeEntryUpdateSerializer, - WeeklySummarySerializer + WeeklySummarySerializer, ) - + + +User = get_user_model() + + class TimeEntryListView(generics.ListAPIView): serializer_class = TimeEntrySerializer permission_classes = [IsAuthenticated] def get_queryset(self): user = self.request.user - # Staff members can view all entries, regular users only their own + if user.is_staff or user.is_superuser: return TimeEntry.objects.all() - return TimeEntry.objects.filter(user=user) + + return TimeEntry.objects.filter( + user=user, + ) + class TimeEntryCreateView(generics.CreateAPIView): serializer_class = TimeEntryCreateSerializer permission_classes = [IsAuthenticated] def perform_create(self, serializer): - requested_user = serializer.validated_data.pop('user', None) + requested_user = serializer.validated_data.get('user') if requested_user is not None: if not ( @@ -45,21 +56,36 @@ class TimeEntryCreateView(generics.CreateAPIView): "Vous ne pouvez pas créer un pointage pour un autre utilisateur." ) - self.created_entry = serializer.save(user=requested_user) + self.created_entry = serializer.save( + user=requested_user, + ) + else: - self.created_entry = serializer.save(user=self.request.user) + self.created_entry = serializer.save( + user=self.request.user, + ) def create(self, request, *args, **kwargs): - serializer = self.get_serializer(data=request.data) - serializer.is_valid(raise_exception=True) + serializer = self.get_serializer( + data=request.data, + ) + + serializer.is_valid( + raise_exception=True, + ) + self.perform_create(serializer) output_serializer = TimeEntrySerializer( self.created_entry, - context={'request': request}, + context={ + 'request': request, + }, ) - headers = self.get_success_headers(serializer.data) + headers = self.get_success_headers( + serializer.data, + ) return Response( output_serializer.data, @@ -67,180 +93,690 @@ class TimeEntryCreateView(generics.CreateAPIView): headers=headers, ) + class TimeEntryScanView(APIView): permission_classes = [IsAuthenticated] - def post(self, request): - code = str(request.data.get('code') or '').strip().strip('"\'') + def _parse_code(self, raw_code): + code = str( + raw_code or '' + ).strip().strip('"\'"') + if '=' in code: - code = code.split('=', 1)[1].strip().strip('"\'') - enter_code = config('SECRET_POINTAGE_ENTER', default='').strip().strip('"\'') - exit_code = config('SECRET_POINTAGE_EXIT', default='').strip().strip('"\'') + code = ( + code + .split('=', 1)[1] + .strip() + .strip('"\'"') + ) + + return code + + def _parse_time(self, value): + if not value: + now = timezone.localtime() + + return now.time().replace( + second=0, + microsecond=0, + ) + + try: + return datetime_time.fromisoformat( + str(value)[:5], + ) + except ValueError: + return None + + def _serialize_entry(self, entry, request): + return TimeEntrySerializer( + entry, + context={ + 'request': request, + }, + ).data + + def post(self, request): + code = self._parse_code( + request.data.get('code'), + ) + + 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: action = 'exit' + else: - return Response({'error': 'QR code de pointage invalide.'}, status=status.HTTP_400_BAD_REQUEST) + return Response( + { + 'error': 'QR code de pointage invalide.' + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + selected_time = self._parse_time( + request.data.get('time'), + ) + + if selected_time is None: + return Response( + { + 'error': 'Heure invalide.' + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + resolution = request.data.get( + 'resolution', + ) + + # Heure réelle du scan. + # + # Lors d'une résolution d'un conflit "sortie sans entrée", + # on utilise scan_time comme heure de sortie et "time" + # comme heure d'entrée. + scan_time_value = request.data.get( + 'scan_time', + ) + + scan_time = self._parse_time( + scan_time_value, + ) + + if scan_time is None: + scan_time = selected_time 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') + today = now.date() with transaction.atomic(): - open_entry = TimeEntry.objects.select_for_update().filter( - user=request.user, - date=now.date(), - 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() + + # ===================================================== + # RÉSOLUTION : entrée déjà ouverte + # ===================================================== + + if resolution == 'complete_exit': + open_entry = ( + TimeEntry.objects + .select_for_update() + .filter( + user=request.user, + start_time__isnull=False, + end_time__isnull=True, + ) + .order_by( + '-date', + '-start_time', + ) + .first() + ) + + if not open_entry: + return Response( + { + 'error': 'Aucune entrée ouverte à compléter.' + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + 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': 'exit', + 'message': 'Sortie enregistrée.', + 'entry': self._serialize_entry( + open_entry, + request, + ), + } + ) + + # ===================================================== + # RÉSOLUTION : sortie scannée sans entrée + # ===================================================== + + if resolution == 'complete_missing_entry': + missing_entry_time = selected_time + exit_time = scan_time + + if missing_entry_time >= exit_time: + return Response( + { + 'error': "L'heure d'entrée doit être avant l'heure de sortie." + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + entry = TimeEntry.objects.create( + user=request.user, + date=today, + start_time=missing_entry_time, + end_time=exit_time, + ) + + return Response( + { + 'action': 'exit', + 'message': 'Entrée et sortie enregistrées.', + 'entry': self._serialize_entry( + entry, + request, + ), + } + ) + + # ===================================================== + # RÉSOLUTION : sortie existante sans entrée + # ===================================================== + + if resolution == 'complete_open_exit': + open_exit = ( + TimeEntry.objects + .select_for_update() + .filter( + user=request.user, + start_time__isnull=True, + end_time__isnull=False, + ) + .order_by( + '-date', + '-end_time', + ) + .first() + ) + + if not open_exit: + return Response( + { + 'error': 'Aucune sortie incomplète à compléter.' + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + 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': 'enter', + 'message': 'Entrée enregistrée.', + 'entry': self._serialize_entry( + open_exit, + request, + ), + } + ) + + # ===================================================== + # POINTAGE EXISTANT DU JOUR + # ===================================================== + + open_entry = ( + TimeEntry.objects + .select_for_update() + .filter( + user=request.user, + date=today, + 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=today, + start_time__isnull=True, + end_time__isnull=False, + ) + .order_by( + '-end_time', + ) + .first() + ) + + # ===================================================== + # QR ENTRÉE + # ===================================================== if action == 'enter': + + # Une entrée est déjà ouverte. + # + # IMPORTANT : + # on ne supprime plus l'entrée si l'utilisateur + # refuse. Le frontend demande d'abord confirmation. if open_entry: - 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) + return Response( + { + 'error': ( + 'Une entrée est déjà ouverte. ' + 'Voulez-vous enregistrer une sortie ?' + ), + 'conflict': 'open_entry', + 'resolution': 'complete_exit', + 'missing_time': 'end_time', + 'message': ( + 'Une entrée est déjà ouverte. ' + 'Voulez-vous enregistrer une sortie ?' + ), + 'scan_time': selected_time.strftime( + '%H:%M' + ), + }, + status=status.HTTP_409_CONFLICT, + ) + + # Une sortie existe sans entrée. 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: + return Response( + { + 'error': ( + 'Une sortie existe déjà sans entrée.' + ), + 'conflict': 'open_exit', + 'resolution': 'complete_open_exit', + 'missing_time': 'start_time', + 'message': ( + 'Une sortie existe déjà sans entrée. ' + 'Voulez-vous renseigner l’heure d’entrée ?' + ), + }, + status=status.HTTP_409_CONFLICT, + ) + + entry = TimeEntry.objects.create( + user=request.user, + date=today, + start_time=selected_time, + ) + + return Response( + { + 'action': 'enter', + 'message': 'Entrée enregistrée.', + 'entry': self._serialize_entry( + entry, + request, + ), + } + ) + + # ===================================================== + # QR SORTIE + # ===================================================== + + # Entrée ouverte aujourd'hui : + # sortie normale. + if open_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']) - 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: - previous_open_entry = TimeEntry.objects.select_for_update().filter( + + open_entry.save( + update_fields=[ + 'end_time', + 'updated_at', + ], + ) + + return Response( + { + 'action': 'exit', + 'message': 'Sortie enregistrée.', + 'entry': self._serialize_entry( + open_entry, + request, + ), + } + ) + + # ===================================================== + # SORTIE SANS ENTRÉE + # ===================================================== + + if open_exit: + return Response( + { + 'error': ( + 'Une sortie existe déjà sans entrée.' + ), + 'conflict': 'open_exit', + 'resolution': 'complete_open_exit', + 'missing_time': 'start_time', + 'message': ( + 'Une sortie existe déjà sans entrée. ' + 'Voulez-vous renseigner l’heure d’entrée ?' + ), + }, + status=status.HTTP_409_CONFLICT, + ) + + # ===================================================== + # POINTAGE OUVERT SUR UN JOUR PRÉCÉDENT + # ===================================================== + + 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: - last_date = previous_open_entry.date - previous_open_entry.end_time = datetime_time(23, 59, 59) - previous_open_entry.save(update_fields=['end_time', 'updated_at']) + ) + .exclude( + date=today, + ) + .order_by( + '-date', + '-start_time', + ) + .first() + ) - current_date = last_date + timedelta(days=1) - while current_date < now.date(): - TimeEntry.objects.create( - user=request.user, - date=current_date, - start_time=datetime_time.min, - end_time=datetime_time(23, 59, 59), - ) - current_date += timedelta(days=1) + if previous_open_entry: + last_date = previous_open_entry.date - entry = TimeEntry.objects.create(user=request.user, date=now.date(), end_time=selected_time) - message = 'Sortie enregistrée sur plusieurs jours.' - else: - entry = TimeEntry.objects.create(user=request.user, date=now.date(), end_time=selected_time) - message = 'Sortie enregistrée sans entrée correspondante.' + group_id = ( + previous_open_entry.group_id + or previous_open_entry.id + ) - return Response({'action': action, 'message': message, 'entry': TimeEntrySerializer(entry).data}) + # Si l'ancien pointage n'avait pas encore de groupe, + # on lui attribue un UUID basé sur un nouvel objet. + if not previous_open_entry.group_id: + import uuid -class TimeEntryDetailView(generics.RetrieveUpdateDestroyAPIView): + group_id = uuid.uuid4() + + previous_open_entry.group_id = group_id + + previous_open_entry.end_time = datetime_time( + 23, + 59, + 59, + ) + + previous_open_entry.save( + update_fields=[ + 'group_id', + 'end_time', + 'updated_at', + ], + ) + + current_date = ( + last_date + + timedelta(days=1) + ) + + while current_date < today: + TimeEntry.objects.create( + user=request.user, + group_id=group_id, + date=current_date, + start_time=datetime_time.min, + end_time=datetime_time( + 23, + 59, + 59, + ), + ) + + current_date += timedelta( + days=1, + ) + + entry = TimeEntry.objects.create( + user=request.user, + group_id=group_id, + date=today, + end_time=selected_time, + ) + + return Response( + { + 'action': 'exit', + 'message': 'Sortie enregistrée sur plusieurs jours.', + 'entry': self._serialize_entry( + entry, + request, + ), + } + ) + + # ===================================================== + # AUCUNE ENTRÉE + # + # On NE crée rien immédiatement. + # Le frontend demande d'abord confirmation. + # ===================================================== + + return Response( + { + 'error': ( + 'Aucune entrée ouverte. ' + 'Voulez-vous enregistrer une sortie ?' + ), + 'conflict': 'missing_entry', + 'resolution': 'complete_missing_entry', + 'missing_time': 'start_time', + 'message': ( + 'Aucune entrée ouverte. ' + 'Voulez-vous enregistrer une sortie ?' + ), + 'scan_time': selected_time.strftime( + '%H:%M' + ), + }, + status=status.HTTP_409_CONFLICT, + ) + + +class TimeEntryDetailView( + generics.RetrieveUpdateDestroyAPIView +): serializer_class = TimeEntrySerializer permission_classes = [IsAuthenticated] lookup_url_kwarg = 'entry_id' def get_queryset(self): user = self.request.user - # Staff members can view all entries, regular users only their own + if user.is_staff or user.is_superuser: return TimeEntry.objects.all() - return TimeEntry.objects.filter(user=user) + + return TimeEntry.objects.filter( + user=user, + ) + class TimeEntryUpdateView(APIView): permission_classes = [IsAuthenticated] def put(self, request, entry_id): try: - entry = TimeEntry.objects.get(id=entry_id) - # Check if user is authorized to update this entry - if not (request.user.is_staff or request.user.is_superuser or entry.user == request.user): - return Response( - {'error': 'Accès refusé'}, - status=status.HTTP_403_FORBIDDEN - ) - - serializer = TimeEntryUpdateSerializer(entry, data=request.data) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + entry = TimeEntry.objects.get( + id=entry_id, + ) + except TimeEntry.DoesNotExist: return Response( - {'error': 'Pointage non trouvé'}, - status=status.HTTP_404_NOT_FOUND + { + 'error': 'Pointage non trouvé' + }, + status=status.HTTP_404_NOT_FOUND, ) + if not ( + request.user.is_staff + or request.user.is_superuser + or entry.user == request.user + ): + return Response( + { + 'error': 'Accès refusé' + }, + status=status.HTTP_403_FORBIDDEN, + ) + + serializer = TimeEntryUpdateSerializer( + entry, + data=request.data, + ) + + if serializer.is_valid(): + serializer.save() + + return Response( + serializer.data, + ) + + return Response( + serializer.errors, + status=status.HTTP_400_BAD_REQUEST, + ) + + def build_weekly_summary(user): today = timezone.localdate() - start_of_week = today - timedelta(days=today.weekday()) - end_of_week = start_of_week + timedelta(days=6) - entries = list(TimeEntry.objects.filter( - user=user, - date__range=(start_of_week, end_of_week), - ).order_by('date')) + + start_of_week = ( + today + - timedelta( + days=today.weekday(), + ) + ) + + end_of_week = ( + start_of_week + + timedelta(days=6) + ) + + entries = list( + TimeEntry.objects.filter( + user=user, + date__range=( + start_of_week, + end_of_week, + ), + ).order_by( + 'date', + 'start_time', + ) + ) + entries_by_date = {} + for entry in entries: - entries_by_date.setdefault(entry.date, entry) + entries_by_date.setdefault( + entry.date, + entry, + ) days_data = [] + for day_offset in range(7): - current_day = start_of_week + timedelta(days=day_offset) - entry = entries_by_date.get(current_day) - days_data.append({ - 'date': current_day.isoformat(), - 'day': current_day.strftime('%A'), - 'start_time': str(entry.start_time) if entry and entry.start_time else None, - 'end_time': str(entry.end_time) if entry and entry.end_time else None, - 'duration': entry.duration if entry else 0, - }) + current_day = ( + start_of_week + + timedelta( + days=day_offset, + ) + ) + + entry = entries_by_date.get( + current_day, + ) + + days_data.append( + { + 'date': current_day.isoformat(), + 'day': current_day.strftime('%A'), + 'start_time': ( + str(entry.start_time) + if entry and entry.start_time + else None + ), + 'end_time': ( + str(entry.end_time) + if entry and entry.end_time + else None + ), + 'duration': ( + entry.duration + if entry + else 0 + ), + } + ) return { 'user': user.username, 'username': user.username, - 'total_hours': round(sum(entry.duration or 0 for entry in entries), 2), - 'days': days_data, + 'total_hours': round( + sum( + entry.duration or 0 + for entry in entries + ), + 2, + ), + 'entries': days_data, } @@ -249,11 +785,31 @@ class WeeklySummaryView(APIView): def get(self, request, user_id=None): user = request.user - if user_id and (request.user.is_staff or request.user.is_superuser): - try: - user = settings.AUTH_USER_MODEL.objects.get(id=user_id) - except settings.AUTH_USER_MODEL.DoesNotExist: - return Response({'error': 'Utilisateur non trouvé'}, status=status.HTTP_404_NOT_FOUND) - serializer = WeeklySummarySerializer(build_weekly_summary(user)) - return Response(serializer.data) + if ( + user_id + and ( + request.user.is_staff + or request.user.is_superuser + ) + ): + try: + user = User.objects.get( + id=user_id, + ) + + except User.DoesNotExist: + return Response( + { + 'error': 'Utilisateur non trouvé' + }, + status=status.HTTP_404_NOT_FOUND, + ) + + serializer = WeeklySummarySerializer( + build_weekly_summary(user), + ) + + return Response( + serializer.data, + ) \ No newline at end of file diff --git a/frontend/src/api/time.js b/frontend/src/api/time.js index a3e13b7..dd204c0 100644 --- a/frontend/src/api/time.js +++ b/frontend/src/api/time.js @@ -1,95 +1,176 @@ import api from './axios'; export const timeApi = { - // Get all time entries getTimeEntries: async () => { try { const response = await api.get('/time/entries/'); return response.data; } catch (error) { - throw new Error('Failed to fetch time entries: ' + error.message); + throw new Error( + 'Failed to fetch time entries: ' + + error.message, + ); } }, - // Get all users (admin only) getUsers: async () => { try { const response = await api.get('/users/'); return response.data; } catch (error) { - throw new Error('Failed to fetch users: ' + error.message); + throw new Error( + 'Failed to fetch users: ' + + error.message, + ); } }, - // Create a new time entry createTimeEntry: async (entryData) => { try { - const response = await api.post('/time/entries/create/', entryData); + const response = await api.post( + '/time/entries/create/', + entryData, + ); + return response.data; } catch (error) { - throw new Error('Failed to create time entry: ' + error.message); + const message = + error.response?.data?.detail || + error.response?.data?.error || + error.message; + + throw new Error( + "Erreur lors de la création du pointage : " + + message, + ); } }, - scanTimeEntry: async (code, options = {}) => { + scanTimeEntry: async ( + code, + options = {}, + ) => { try { - const response = await api.post('/time/entries/scan/', { code, ...options }); + const response = await api.post( + '/time/entries/scan/', + { + code, + ...options, + }, + ); + return response.data; } catch (error) { - 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; + const data = + error.response?.data || {}; + + const scanError = new Error( + data.error || + `Erreur lors du pointage: ${error.message}`, + ); + + scanError.conflict = + data.conflict; + + scanError.resolution = + data.resolution; + + scanError.missingTime = + data.missing_time; + + scanError.scanTime = + data.scan_time; + + scanError.code = + error.response?.status; + throw scanError; } }, - // Get a specific time entry getTimeEntry: async (id) => { try { - const response = await api.get(`/time/entries/${id}/`); + const response = await api.get( + `/time/entries/${id}/`, + ); + return response.data; } catch (error) { - throw new Error('Failed to fetch time entry: ' + error.message); + throw new Error( + 'Failed to fetch time entry: ' + + error.message, + ); } }, - // Update a time entry - updateTimeEntry: async (id, entryData) => { + updateTimeEntry: async ( + id, + entryData, + ) => { try { - const response = await api.put(`/time/entries/${id}/update/`, entryData); + const response = await api.put( + `/time/entries/${id}/update/`, + entryData, + ); + return response.data; } catch (error) { - throw new Error('Failed to update time entry: ' + error.message); + const message = + error.response?.data?.detail || + error.response?.data?.error || + error.message; + + throw new Error( + 'Erreur lors de la mise à jour : ' + + message, + ); } }, - // Delete a time entry deleteTimeEntry: async (id) => { try { - const response = await api.delete(`/time/entries/${id}/`); + const response = await api.delete( + `/time/entries/${id}/`, + ); + return response.data; } catch (error) { - throw new Error(error.response?.data?.detail || `Erreur lors de la suppression: ${error.message}`); + throw new Error( + error.response?.data?.detail || + `Erreur lors de la suppression: ${error.message}`, + ); } }, - // Get weekly summary for current user getWeeklySummary: async () => { try { - const response = await api.get('/time/weekly-summary/'); + const response = await api.get( + '/time/weekly-summary/', + ); + return response.data; } catch (error) { - throw new Error('Failed to fetch weekly summary: ' + error.message); + throw new Error( + 'Failed to fetch weekly summary: ' + + error.message, + ); } }, - // Get weekly summary for a specific user (admin only) - getUserWeeklySummary: async (userId) => { + getUserWeeklySummary: async ( + userId, + ) => { try { - const response = await api.get(`/time/weekly-summary/${userId}/`); + const response = await api.get( + `/time/weekly-summary/${userId}/`, + ); + return response.data; } catch (error) { - throw new Error('Failed to fetch user weekly summary: ' + error.message); + throw new Error( + 'Failed to fetch user weekly summary: ' + + error.message, + ); } }, }; \ No newline at end of file diff --git a/frontend/src/pages/TimeClockPage.jsx b/frontend/src/pages/TimeClockPage.jsx index c8e6e5e..361cb94 100644 --- a/frontend/src/pages/TimeClockPage.jsx +++ b/frontend/src/pages/TimeClockPage.jsx @@ -433,6 +433,8 @@ const TimeClockPage = () => { editForm, setEditForm, ] = useState({ + date: '', + end_date: '', start_time: '', end_time: '', }); @@ -546,7 +548,6 @@ const TimeClockPage = () => { return ''; }; - const getEntryUserLabel = (entry) => { if (!entry) { return 'Utilisateur inconnu'; @@ -583,6 +584,10 @@ const TimeClockPage = () => { ); }; + const getDisplayTimeEntries = + () => { + return timeEntries; + }; /* ========================================================= LOAD ENTRIES @@ -766,162 +771,113 @@ const TimeClockPage = () => { ========================================================= */ const handleAddEntry = - async (e) => { - e.preventDefault(); + async (e) => { + e.preventDefault(); - if ( - !newEntry.start_time || - !newEntry.end_time - ) { - setError( - 'Veuillez remplir tous les champs', - ); - return; - } + if ( + !newEntry.start_time || + !newEntry.end_time + ) { + setError( + 'Veuillez renseigner une heure d’entrée et une heure de sortie.', + ); - if ( - isAdmin && - !showAllUsers && - !selectedUserId - ) { - setError( - 'Veuillez sélectionner un utilisateur.', - ); - return; - } + return; + } - try { - if ( - newEntry.date === - newEntry.end_date - ) { - await timeApi.createTimeEntry({ - date: newEntry.date, - start_time: - newEntry.start_time, - end_time: - newEntry.end_time, + if ( + !newEntry.date || + !newEntry.end_date + ) { + setError( + 'Veuillez renseigner les dates.', + ); - ...(isAdmin && - selectedUserId - ? { - user_id: - selectedUserId, - } - : {}), - }); - } else { - const dates = []; + return; + } - const cursor = new Date( - `${newEntry.date}T12:00:00`, - ); + if ( + newEntry.end_date < + newEntry.date + ) { + setError( + 'La date de sortie doit être postérieure ou égale à la date d’entrée.', + ); - const lastDate = new Date( - `${newEntry.end_date}T12:00:00`, - ); + return; + } - while ( - cursor <= lastDate - ) { - dates.push( - cursor - .toISOString() - .slice(0, 10), - ); + if ( + newEntry.date === + newEntry.end_date && + newEntry.end_time <= + newEntry.start_time + ) { + setError( + "L'heure de sortie doit être après l'heure d'entrée.", + ); - cursor.setDate( - cursor.getDate() + 1, - ); - } + return; + } - await Promise.all( - dates.map( - (date, index) => { - if (index === 0) { - return timeApi.createTimeEntry( - { - date, - start_time: - newEntry.start_time, + if ( + isAdmin && + !showAllUsers && + !selectedUserId + ) { + setError( + 'Veuillez sélectionner un utilisateur.', + ); - ...(isAdmin && - selectedUserId - ? { - user_id: - selectedUserId, - } - : {}), - }, - ); - } + return; + } - if ( - index === - dates.length - 1 - ) { - return timeApi.createTimeEntry( - { - date, - end_time: - newEntry.end_time, + try { + await timeApi.createTimeEntry({ + date: newEntry.date, - ...(isAdmin && - selectedUserId - ? { - user_id: - selectedUserId, - } - : {}), - }, - ); - } + end_date: + newEntry.end_date, - return timeApi.createTimeEntry( - { - date, - start_time: - '00:00', - end_time: - '23:59', + start_time: + newEntry.start_time, - ...(isAdmin && - selectedUserId - ? { - user_id: - selectedUserId, - } - : {}), - }, - ); - }, - ), - ); - } + end_time: + newEntry.end_time, - setNewEntry({ - date: new Date() - .toISOString() - .split('T')[0], + ...(isAdmin && + selectedUserId + ? { + user_id: + selectedUserId, + } + : {}), + }); - end_date: new Date() - .toISOString() - .split('T')[0], + const today = + new Date() + .toISOString() + .split('T')[0]; - start_time: '', - end_time: '', - }); + setNewEntry({ + date: today, + end_date: today, + start_time: '', + end_time: '', + }); - loadTimeEntries(); - setError(''); - } catch (err) { - setError( + await loadTimeEntries(); + + setError(''); + } catch (err) { + setError( + err.message || "Erreur lors de l'ajout du pointage", - ); + ); - console.error(err); - } - }; + console.error(err); + } + }; /* ========================================================= @@ -933,10 +889,15 @@ const TimeClockPage = () => { setEditingEntry(entry); setEditForm({ + date: entry.date || '', + end_date: + entry.end_date || + entry.date || + '', start_time: - entry.start_time, + entry.start_time || '', end_time: - entry.end_time, + entry.end_time || '', }); }; @@ -1053,88 +1014,209 @@ const TimeClockPage = () => { const handleQrResult = - async ( - code, - sessionId, - ) => { + async ( + code, + sessionId, + ) => { + if ( + sessionId !== + scannerSessionRef.current + ) { + return; + } + + setError(''); + setScanMessage(''); + setScanConflict(null); + + closeScanner(); + + try { + const response = + await timeApi.scanTimeEntry( + code, + ); + + setScanMessage( + response.message, + ); + + await loadTimeEntries(); + } catch ( + scanError + ) { if ( - sessionId !== - scannerSessionRef.current + scanError.code === 409 && + scanError.conflict ) { + const nowTime = + new Date() + .toTimeString() + .slice(0, 5); + + setConflictTime( + scanError.scanTime || + nowTime, + ); + + setScanConflict({ + code, + type: + scanError.conflict, + message: + scanError.message, + resolution: + scanError.resolution, + missingTime: + scanError.missingTime, + scanTime: + scanError.scanTime, + step: 'confirm', + }); + return; } + setError( + scanError.message, + ); + } + }; + + + const getScanConflictConfig = () => { + if (!scanConflict) { + return null; + } + + if ( + scanConflict.type === 'open_entry' + ) { + return { + confirmationTitle: + 'Une entrée est déjà ouverte', + + confirmationMessage: + 'Voulez-vous enregistrer une sortie pour cette entrée ?', + + timeLabel: + 'Heure de sortie', + + resolution: + 'complete_exit', + }; + } + + if ( + scanConflict.type === 'missing_entry' + ) { + return { + confirmationTitle: + 'Aucune entrée ouverte', + + confirmationMessage: + 'Voulez-vous enregistrer cette sortie malgré l’absence d’entrée ?', + + timeLabel: + "Heure d'entrée", + + resolution: + 'complete_missing_entry', + }; + } + + if ( + scanConflict.type === 'open_exit' + ) { + return { + confirmationTitle: + 'Une sortie sans entrée existe', + + confirmationMessage: + 'Voulez-vous renseigner l’heure d’entrée ?', + + timeLabel: + "Heure d'entrée", + + resolution: + 'complete_open_exit', + }; + } + + return null; +}; + + +const startScanConflictResolution = + () => { + setScanConflict( + (current) => + current + ? { + ...current, + step: 'time', + } + : null, + ); + }; + + +const cancelScanConflict = () => { + setScanConflict(null); + setError(''); +}; + + +const resolveScanConflict = + async () => { + if (!scanConflict) { + return; + } + + const config = + getScanConflictConfig(); + + if (!config) { + return; + } + + setError(''); + + try { + const options = { + resolution: + config.resolution, + time: conflictTime, + }; + + if ( + scanConflict.scanTime + ) { + options.scan_time = + scanConflict.scanTime; + } + + const response = + await timeApi.scanTimeEntry( + scanConflict.code, + options, + ); + + setScanMessage( + response.message, + ); + setScanConflict(null); - closeScanner(); - - try { - const response = - await timeApi.scanTimeEntry( - code, - ); - - setScanMessage( - response.message, - ); - - await loadTimeEntries(); - } catch ( - scanError - ) { - 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, - ); - } - }; + await loadTimeEntries(); + } catch ( + resolutionError + ) { + setError( + resolutionError.message, + ); + } + }; useEffect(() => { @@ -1439,7 +1521,7 @@ const TimeClockPage = () => { timeString, ) => { if (!timeString) { - return '00h00'; + return '────'; } const [ @@ -1447,10 +1529,6 @@ const TimeClockPage = () => { minutes, ] = timeString.split(':'); - if (hours === '24') { - return '23h59'; - } - return `${String(hours).padStart( 2, '0', @@ -1460,66 +1538,6 @@ const TimeClockPage = () => { )}`; }; - - const getDisplayTimeEntries = - () => { - const toMinutes = - (time) => { - const [ - hours, - minutes, - ] = time - .split(':') - .map(Number); - - return ( - hours * 60 + - minutes - ); - }; - - return timeEntries.map( - (entry) => { - if ( - entry.start_time && - !entry.end_time - ) { - return { - ...entry, - end_time: - '24:00:00', - - duration: - (1440 - - toMinutes( - entry.start_time, - )) / - 60, - }; - } - - if ( - !entry.start_time && - entry.end_time - ) { - return { - ...entry, - start_time: - '00:00:00', - - duration: - toMinutes( - entry.end_time, - ) / 60, - }; - } - - return entry; - }, - ); - }; - - const formatDate = ( dateString, ) => { @@ -1587,7 +1605,16 @@ const TimeClockPage = () => { ========================================================= */ return ( - + /* + MOBILE : + - le Card principal devient le conteneur scrollable + - son contenu peut prendre toute sa hauteur + + DESKTOP : + - le Card principal ne scroll pas + - le scroll est délégué à l'historique + */ + @@ -1657,57 +1684,101 @@ const TimeClockPage = () => { - Conflit de pointage + {scanConflict.step === 'confirm' + ? 'Confirmation du pointage' + : 'Heure manquante'} - -

- {scanConflict.message} -

+ + {(() => { + const config = + getScanConflictConfig(); - - - -
+ if ( + scanConflict.step === + 'confirm' + ) { + return ( + <> +

+ {config.confirmationMessage} +

- +
+ - + +
+ + ); + } -
+ return ( + <> +

+ {config.confirmationMessage} +

+ +
+ + +
+ +
+
+ +
+ + + +
+ + ); + })()}
)} @@ -1789,7 +1860,7 @@ const TimeClockPage = () => { GESTION + HISTORIQUE ====================================================== */} -
+
{/* ================================================= COLONNE GAUCHE @@ -1932,6 +2003,43 @@ const TimeClockPage = () => { >
+
+ + + + setEditForm({ + ...editForm, + date: value, + end_date: + editForm.end_date && + editForm.end_date < value + ? value + : editForm.end_date, + }) + } + /> +
+ +
+ + + + setEditForm({ + ...editForm, + end_date: value, + }) + } + /> +
- setEditForm( - { - ...editForm, - start_time: - value, - }, - ) + value={editForm.start_time} + onChange={(value) => + setEditForm({ + ...editForm, + start_time: value, + }) } />
-
- setEditForm( - { - ...editForm, - end_time: - value, - }, - ) + value={editForm.end_time} + onChange={(value) => + setEditForm({ + ...editForm, + end_time: value, + }) } />
@@ -2143,11 +2236,20 @@ const TimeClockPage = () => { HISTORIQUE ================================================== */} -
+
- + {/* + MOBILE : + max-h-[60vh] limite la hauteur de l'historique + pour permettre son propre scroll. - + DESKTOP : + lg:h-full permet à l'historique de remplir + toute la colonne disponible. + */} + + + Historique des pointages{' '} {!showAllUsers && @@ -2158,7 +2260,11 @@ const TimeClockPage = () => { - + {/* + C'est CE conteneur qui possède le scroll + de l'historique sur PC et mobile. + */} + {loading ? (

@@ -2198,11 +2304,15 @@ const TimeClockPage = () => { Sortie - + Durée + + Statut + + {isAdmin && ( Actions @@ -2262,14 +2372,37 @@ const TimeClockPage = () => { )} - - - {formatDuration( - entry.duration, - )} - + + {entry.duration !== null && + entry.duration !== undefined ? ( + + {formatDuration( + entry.duration, + )} + + ) : ( + + ──── + + )} + + + + {entry.start_time && + entry.end_time ? ( + + Complet + + ) : entry.start_time ? ( + + Entrée ouverte + + ) : ( + + Sortie sans entrée + + )} - {isAdmin && (