This commit is contained in:
Louis Mazin 2026-09-20 04:02:02 +02:00
parent 9c3b068646
commit 923f371e8b
6 changed files with 1567 additions and 568 deletions

View File

@ -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),
),
]

View File

@ -1,6 +1,8 @@
import uuid
from datetime import datetime
from django.conf import settings from django.conf import settings
from django.db import models from django.db import models
from datetime import datetime
class TimeEntry(models.Model): class TimeEntry(models.Model):
@ -10,29 +12,92 @@ class TimeEntry(models.Model):
related_name='time_entries', 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() date = models.DateField()
start_time = models.TimeField(null=True, blank=True)
end_time = models.TimeField(null=True, blank=True) start_time = models.TimeField(
created_at = models.DateTimeField(auto_now_add=True) null=True,
updated_at = models.DateTimeField(auto_now=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: class Meta:
ordering = ['-date', '-start_time'] ordering = ['-date', '-start_time']
unique_together = ['user', 'date', 'start_time']
unique_together = [
'user',
'date',
'start_time',
]
def __str__(self): 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 @property
def duration(self): def duration(self):
"""Calculate duration in hours""" if not self.start_time or not self.end_time:
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 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 @property
def day_of_week(self): def day_of_week(self):
"""Get day of week as string"""
return self.date.strftime('%A') 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

View File

@ -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.contrib.auth import get_user_model
from django.db import transaction
from rest_framework import serializers
from .models import TimeEntry from .models import TimeEntry
@ -10,17 +14,27 @@ User = get_user_model()
class UserSerializer(serializers.ModelSerializer): class UserSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = User model = User
fields = ['id', 'username', 'first_name', 'last_name'] fields = [
'id',
'username',
'first_name',
'last_name',
]
class TimeEntrySerializer(serializers.ModelSerializer): class TimeEntrySerializer(serializers.ModelSerializer):
user = UserSerializer(read_only=True) user = UserSerializer(read_only=True)
is_complete = serializers.ReadOnlyField()
is_incomplete = serializers.ReadOnlyField()
class Meta: class Meta:
model = TimeEntry model = TimeEntry
fields = [ fields = [
'id', 'id',
'user', 'user',
'group_id',
'date', 'date',
'start_time', 'start_time',
'end_time', 'end_time',
@ -28,7 +42,10 @@ class TimeEntrySerializer(serializers.ModelSerializer):
'updated_at', 'updated_at',
'duration', 'duration',
'day_of_week', 'day_of_week',
'is_complete',
'is_incomplete',
] ]
read_only_fields = [ read_only_fields = [
'id', 'id',
'user', 'user',
@ -36,6 +53,8 @@ class TimeEntrySerializer(serializers.ModelSerializer):
'updated_at', 'updated_at',
'duration', 'duration',
'day_of_week', 'day_of_week',
'is_complete',
'is_incomplete',
] ]
def create(self, validated_data): def create(self, validated_data):
@ -55,46 +74,171 @@ class TimeEntryCreateSerializer(serializers.ModelSerializer):
required=False, required=False,
) )
end_date = serializers.DateField(
write_only=True,
required=False,
)
class Meta: class Meta:
model = TimeEntry model = TimeEntry
fields = ['user_id', 'date', 'start_time', 'end_time']
fields = [
'user_id',
'date',
'end_date',
'start_time',
'end_time',
]
def validate(self, attrs): 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( raise serializers.ValidationError(
'Une heure d’entrée ou de sortie est requise.' '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 ( if (
attrs.get('start_time') date == end_date
and attrs.get('end_time') and start_time
and attrs['end_time'] <= attrs['start_time'] and end_time
and end_time <= start_time
): ):
raise serializers.ValidationError( raise serializers.ValidationError(
'L’heure de sortie doit être après l’entrée.' '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 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 TimeEntryUpdateSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = TimeEntry model = TimeEntry
fields = ['start_time', 'end_time']
fields = [
'start_time',
'end_time',
]
def validate(self, attrs): 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( raise serializers.ValidationError(
'Une heure d’entrée ou de sortie est requise.' 'Une heure d’entrée ou de sortie est requise.'
) )
if ( if (
attrs.get('start_time') start_time
and attrs.get('end_time') and end_time
and attrs['end_time'] <= attrs['start_time'] and end_time <= start_time
): ):
raise serializers.ValidationError( 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 return attrs
@ -104,4 +248,6 @@ class WeeklySummarySerializer(serializers.Serializer):
user = serializers.CharField() user = serializers.CharField()
username = serializers.CharField() username = serializers.CharField()
total_hours = serializers.FloatField() total_hours = serializers.FloatField()
entries = serializers.ListField(child=serializers.DictField()) entries = serializers.ListField(
child=serializers.DictField(),
)

View File

@ -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 timedelta
from datetime import time as datetime_time from datetime import time as datetime_time
from decouple import config 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 .models import TimeEntry
from .serializers import ( from .serializers import (
TimeEntrySerializer,
TimeEntryCreateSerializer, TimeEntryCreateSerializer,
TimeEntrySerializer,
TimeEntryUpdateSerializer, TimeEntryUpdateSerializer,
WeeklySummarySerializer WeeklySummarySerializer,
) )
User = get_user_model()
class TimeEntryListView(generics.ListAPIView): class TimeEntryListView(generics.ListAPIView):
serializer_class = TimeEntrySerializer serializer_class = TimeEntrySerializer
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
def get_queryset(self): def get_queryset(self):
user = self.request.user user = self.request.user
# Staff members can view all entries, regular users only their own
if user.is_staff or user.is_superuser: if user.is_staff or user.is_superuser:
return TimeEntry.objects.all() return TimeEntry.objects.all()
return TimeEntry.objects.filter(user=user)
return TimeEntry.objects.filter(
user=user,
)
class TimeEntryCreateView(generics.CreateAPIView): class TimeEntryCreateView(generics.CreateAPIView):
serializer_class = TimeEntryCreateSerializer serializer_class = TimeEntryCreateSerializer
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
def perform_create(self, serializer): 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 requested_user is not None:
if not ( if not (
@ -45,21 +56,36 @@ class TimeEntryCreateView(generics.CreateAPIView):
"Vous ne pouvez pas créer un pointage pour un autre utilisateur." "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: 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): def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data) serializer = self.get_serializer(
serializer.is_valid(raise_exception=True) data=request.data,
)
serializer.is_valid(
raise_exception=True,
)
self.perform_create(serializer) self.perform_create(serializer)
output_serializer = TimeEntrySerializer( output_serializer = TimeEntrySerializer(
self.created_entry, 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( return Response(
output_serializer.data, output_serializer.data,
@ -67,180 +93,690 @@ class TimeEntryCreateView(generics.CreateAPIView):
headers=headers, headers=headers,
) )
class TimeEntryScanView(APIView): class TimeEntryScanView(APIView):
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
def post(self, request): def _parse_code(self, raw_code):
code = str(request.data.get('code') or '').strip().strip('"\'') code = str(
raw_code or ''
).strip().strip('"\'"')
if '=' in code: if '=' in code:
code = code.split('=', 1)[1].strip().strip('"\'') code = (
enter_code = config('SECRET_POINTAGE_ENTER', default='').strip().strip('"\'') code
exit_code = config('SECRET_POINTAGE_EXIT', default='').strip().strip('"\'') .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: if code == enter_code:
action = 'enter' action = 'enter'
elif code == exit_code: elif code == exit_code:
action = 'exit' action = 'exit'
else: 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() now = timezone.localtime()
requested_time = request.data.get('time') today = now.date()
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(): with transaction.atomic():
open_entry = TimeEntry.objects.select_for_update().filter(
# =====================================================
# RÉSOLUTION : entrée déjà ouverte
# =====================================================
if resolution == 'complete_exit':
open_entry = (
TimeEntry.objects
.select_for_update()
.filter(
user=request.user, user=request.user,
date=now.date(),
start_time__isnull=False, start_time__isnull=False,
end_time__isnull=True, end_time__isnull=True,
).order_by('-start_time').first() )
open_exit = TimeEntry.objects.select_for_update().filter( .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, user=request.user,
date=now.date(),
start_time__isnull=True, start_time__isnull=True,
end_time__isnull=False, end_time__isnull=False,
).order_by('-end_time').first() )
.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': 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 open_entry:
if resolution == 'cancel_entry': return Response(
open_entry.delete() {
return Response({'action': action, 'message': 'Entrée annulée.'}) 'error': (
if resolution == 'complete_entry': 'Une entrée est déjà ouverte. '
if selected_time <= open_entry.start_time: 'Voulez-vous enregistrer une sortie ?'
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 'conflict': 'open_entry',
open_entry.save(update_fields=['end_time', 'updated_at']) 'resolution': 'complete_exit',
return Response({'action': action, 'message': 'Sortie enregistrée pour l’entrée précédente.', 'entry': TimeEntrySerializer(open_entry).data}) 'missing_time': 'end_time',
return Response({'error': 'Une entrée est déjà ouverte.', 'conflict': 'open_entry', 'options': ['cancel_entry', 'complete_entry']}, status=status.HTTP_409_CONFLICT) '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 open_exit:
if resolution == 'cancel_exit': return Response(
open_exit.delete() {
return Response({'action': action, 'message': 'Sortie annulée.'}) 'error': (
if resolution == 'complete_exit': 'Une sortie existe déjà sans entrée.'
open_exit.start_time = selected_time ),
open_exit.save(update_fields=['start_time', 'updated_at']) 'conflict': 'open_exit',
return Response({'action': action, 'message': 'Entrée enregistrée pour la sortie précédente.', 'entry': TimeEntrySerializer(open_exit).data}) 'resolution': 'complete_open_exit',
return Response({'error': 'Une sortie existe déjà.', 'conflict': 'open_exit', 'options': ['cancel_exit', 'complete_exit']}, status=status.HTTP_409_CONFLICT) 'missing_time': 'start_time',
entry = TimeEntry.objects.create(user=request.user, date=now.date(), start_time=selected_time) 'message': (
message = 'Entrée enregistrée.' 'Une sortie existe déjà sans entrée. '
elif open_entry: '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.end_time = selected_time
open_entry.save(update_fields=['end_time', 'updated_at'])
entry = open_entry open_entry.save(
message = 'Sortie enregistrée.' update_fields=[
elif open_exit: 'end_time',
if resolution == 'cancel_exit': 'updated_at',
open_exit.delete() ],
return Response({'action': action, 'message': 'Sortie annulée.'}) )
if resolution == 'complete_exit':
if selected_time >= open_exit.end_time: return Response(
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 'action': 'exit',
open_exit.save(update_fields=['start_time', 'updated_at']) 'message': 'Sortie enregistrée.',
return Response({'action': action, 'message': 'Entrée enregistrée pour la sortie précédente.', 'entry': TimeEntrySerializer(open_exit).data}) 'entry': self._serialize_entry(
return Response({'error': 'Une sortie existe déjà.', 'conflict': 'open_exit', 'options': ['cancel_exit', 'complete_exit']}, status=status.HTTP_409_CONFLICT) open_entry,
else: request,
previous_open_entry = TimeEntry.objects.select_for_update().filter( ),
}
)
# =====================================================
# 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, user=request.user,
start_time__isnull=False, start_time__isnull=False,
end_time__isnull=True, end_time__isnull=True,
).exclude(date=now.date()).order_by('-date', '-start_time').first() )
.exclude(
date=today,
)
.order_by(
'-date',
'-start_time',
)
.first()
)
if previous_open_entry: if previous_open_entry:
last_date = previous_open_entry.date 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'])
current_date = last_date + timedelta(days=1) group_id = (
while current_date < now.date(): previous_open_entry.group_id
or previous_open_entry.id
)
# 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
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( TimeEntry.objects.create(
user=request.user, user=request.user,
group_id=group_id,
date=current_date, date=current_date,
start_time=datetime_time.min, start_time=datetime_time.min,
end_time=datetime_time(23, 59, 59), end_time=datetime_time(
23,
59,
59,
),
) )
current_date += timedelta(days=1)
entry = TimeEntry.objects.create(user=request.user, date=now.date(), end_time=selected_time) current_date += timedelta(
message = 'Sortie enregistrée sur plusieurs jours.' days=1,
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}) entry = TimeEntry.objects.create(
user=request.user,
group_id=group_id,
date=today,
end_time=selected_time,
)
class TimeEntryDetailView(generics.RetrieveUpdateDestroyAPIView): 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 serializer_class = TimeEntrySerializer
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
lookup_url_kwarg = 'entry_id' lookup_url_kwarg = 'entry_id'
def get_queryset(self): def get_queryset(self):
user = self.request.user user = self.request.user
# Staff members can view all entries, regular users only their own
if user.is_staff or user.is_superuser: if user.is_staff or user.is_superuser:
return TimeEntry.objects.all() return TimeEntry.objects.all()
return TimeEntry.objects.filter(user=user)
return TimeEntry.objects.filter(
user=user,
)
class TimeEntryUpdateView(APIView): class TimeEntryUpdateView(APIView):
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
def put(self, request, entry_id): def put(self, request, entry_id):
try: try:
entry = TimeEntry.objects.get(id=entry_id) entry = TimeEntry.objects.get(
# Check if user is authorized to update this entry id=entry_id,
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)
except TimeEntry.DoesNotExist: except TimeEntry.DoesNotExist:
return Response( 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): def build_weekly_summary(user):
today = timezone.localdate() today = timezone.localdate()
start_of_week = today - timedelta(days=today.weekday())
end_of_week = start_of_week + timedelta(days=6) start_of_week = (
entries = list(TimeEntry.objects.filter( today
- timedelta(
days=today.weekday(),
)
)
end_of_week = (
start_of_week
+ timedelta(days=6)
)
entries = list(
TimeEntry.objects.filter(
user=user, user=user,
date__range=(start_of_week, end_of_week), date__range=(
).order_by('date')) start_of_week,
end_of_week,
),
).order_by(
'date',
'start_time',
)
)
entries_by_date = {} entries_by_date = {}
for entry in entries: for entry in entries:
entries_by_date.setdefault(entry.date, entry) entries_by_date.setdefault(
entry.date,
entry,
)
days_data = [] days_data = []
for day_offset in range(7): for day_offset in range(7):
current_day = start_of_week + timedelta(days=day_offset) current_day = (
entry = entries_by_date.get(current_day) start_of_week
days_data.append({ + timedelta(
days=day_offset,
)
)
entry = entries_by_date.get(
current_day,
)
days_data.append(
{
'date': current_day.isoformat(), 'date': current_day.isoformat(),
'day': current_day.strftime('%A'), 'day': current_day.strftime('%A'),
'start_time': str(entry.start_time) if entry and entry.start_time else None, 'start_time': (
'end_time': str(entry.end_time) if entry and entry.end_time else None, str(entry.start_time)
'duration': entry.duration if entry else 0, 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 { return {
'user': user.username, 'user': user.username,
'username': user.username, 'username': user.username,
'total_hours': round(sum(entry.duration or 0 for entry in entries), 2), 'total_hours': round(
'days': days_data, 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): def get(self, request, user_id=None):
user = request.user 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)) if (
return Response(serializer.data) 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,
)

View File

@ -1,95 +1,176 @@
import api from './axios'; import api from './axios';
export const timeApi = { export const timeApi = {
// Get all time entries
getTimeEntries: async () => { getTimeEntries: async () => {
try { try {
const response = await api.get('/time/entries/'); const response = await api.get('/time/entries/');
return response.data; return response.data;
} catch (error) { } 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 () => { getUsers: async () => {
try { try {
const response = await api.get('/users/'); const response = await api.get('/users/');
return response.data; return response.data;
} catch (error) { } 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) => { createTimeEntry: async (entryData) => {
try { try {
const response = await api.post('/time/entries/create/', entryData); const response = await api.post(
'/time/entries/create/',
entryData,
);
return response.data; return response.data;
} catch (error) { } 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 { try {
const response = await api.post('/time/entries/scan/', { code, ...options }); const response = await api.post(
'/time/entries/scan/',
{
code,
...options,
},
);
return response.data; return response.data;
} catch (error) { } catch (error) {
const scanError = new Error(error.response?.data?.error || `Erreur lors du pointage: ${error.message}`); const data =
scanError.conflict = error.response?.data?.conflict; error.response?.data || {};
scanError.code = error.response?.status;
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; throw scanError;
} }
}, },
// Get a specific time entry
getTimeEntry: async (id) => { getTimeEntry: async (id) => {
try { try {
const response = await api.get(`/time/entries/${id}/`); const response = await api.get(
`/time/entries/${id}/`,
);
return response.data; return response.data;
} catch (error) { } 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 (
updateTimeEntry: async (id, entryData) => { id,
entryData,
) => {
try { try {
const response = await api.put(`/time/entries/${id}/update/`, entryData); const response = await api.put(
`/time/entries/${id}/update/`,
entryData,
);
return response.data; return response.data;
} catch (error) { } 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) => { deleteTimeEntry: async (id) => {
try { try {
const response = await api.delete(`/time/entries/${id}/`); const response = await api.delete(
`/time/entries/${id}/`,
);
return response.data; return response.data;
} catch (error) { } 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 () => { getWeeklySummary: async () => {
try { try {
const response = await api.get('/time/weekly-summary/'); const response = await api.get(
'/time/weekly-summary/',
);
return response.data; return response.data;
} catch (error) { } 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 (
getUserWeeklySummary: async (userId) => { userId,
) => {
try { try {
const response = await api.get(`/time/weekly-summary/${userId}/`); const response = await api.get(
`/time/weekly-summary/${userId}/`,
);
return response.data; return response.data;
} catch (error) { } catch (error) {
throw new Error('Failed to fetch user weekly summary: ' + error.message); throw new Error(
'Failed to fetch user weekly summary: ' +
error.message,
);
} }
}, },
}; };

View File

@ -433,6 +433,8 @@ const TimeClockPage = () => {
editForm, editForm,
setEditForm, setEditForm,
] = useState({ ] = useState({
date: '',
end_date: '',
start_time: '', start_time: '',
end_time: '', end_time: '',
}); });
@ -546,7 +548,6 @@ const TimeClockPage = () => {
return ''; return '';
}; };
const getEntryUserLabel = (entry) => { const getEntryUserLabel = (entry) => {
if (!entry) { if (!entry) {
return 'Utilisateur inconnu'; return 'Utilisateur inconnu';
@ -583,6 +584,10 @@ const TimeClockPage = () => {
); );
}; };
const getDisplayTimeEntries =
() => {
return timeEntries;
};
/* ========================================================= /* =========================================================
LOAD ENTRIES LOAD ENTRIES
@ -774,8 +779,44 @@ const TimeClockPage = () => {
!newEntry.end_time !newEntry.end_time
) { ) {
setError( setError(
'Veuillez remplir tous les champs', 'Veuillez renseigner une heure d’entrée et une heure de sortie.',
); );
return;
}
if (
!newEntry.date ||
!newEntry.end_date
) {
setError(
'Veuillez renseigner les dates.',
);
return;
}
if (
newEntry.end_date <
newEntry.date
) {
setError(
'La date de sortie doit être postérieure ou égale à la date d’entrée.',
);
return;
}
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.",
);
return; return;
} }
@ -787,18 +828,20 @@ const TimeClockPage = () => {
setError( setError(
'Veuillez sélectionner un utilisateur.', 'Veuillez sélectionner un utilisateur.',
); );
return; return;
} }
try { try {
if (
newEntry.date ===
newEntry.end_date
) {
await timeApi.createTimeEntry({ await timeApi.createTimeEntry({
date: newEntry.date, date: newEntry.date,
end_date:
newEntry.end_date,
start_time: start_time:
newEntry.start_time, newEntry.start_time,
end_time: end_time:
newEntry.end_time, newEntry.end_time,
@ -810,112 +853,25 @@ const TimeClockPage = () => {
} }
: {}), : {}),
}); });
} else {
const dates = [];
const cursor = new Date( const today =
`${newEntry.date}T12:00:00`, new Date()
);
const lastDate = new Date(
`${newEntry.end_date}T12:00:00`,
);
while (
cursor <= lastDate
) {
dates.push(
cursor
.toISOString() .toISOString()
.slice(0, 10), .split('T')[0];
);
cursor.setDate(
cursor.getDate() + 1,
);
}
await Promise.all(
dates.map(
(date, index) => {
if (index === 0) {
return timeApi.createTimeEntry(
{
date,
start_time:
newEntry.start_time,
...(isAdmin &&
selectedUserId
? {
user_id:
selectedUserId,
}
: {}),
},
);
}
if (
index ===
dates.length - 1
) {
return timeApi.createTimeEntry(
{
date,
end_time:
newEntry.end_time,
...(isAdmin &&
selectedUserId
? {
user_id:
selectedUserId,
}
: {}),
},
);
}
return timeApi.createTimeEntry(
{
date,
start_time:
'00:00',
end_time:
'23:59',
...(isAdmin &&
selectedUserId
? {
user_id:
selectedUserId,
}
: {}),
},
);
},
),
);
}
setNewEntry({ setNewEntry({
date: new Date() date: today,
.toISOString() end_date: today,
.split('T')[0],
end_date: new Date()
.toISOString()
.split('T')[0],
start_time: '', start_time: '',
end_time: '', end_time: '',
}); });
loadTimeEntries(); await loadTimeEntries();
setError(''); setError('');
} catch (err) { } catch (err) {
setError( setError(
err.message ||
"Erreur lors de l'ajout du pointage", "Erreur lors de l'ajout du pointage",
); );
@ -933,10 +889,15 @@ const TimeClockPage = () => {
setEditingEntry(entry); setEditingEntry(entry);
setEditForm({ setEditForm({
date: entry.date || '',
end_date:
entry.end_date ||
entry.date ||
'',
start_time: start_time:
entry.start_time, entry.start_time || '',
end_time: end_time:
entry.end_time, entry.end_time || '',
}); });
}; };
@ -1064,6 +1025,8 @@ const TimeClockPage = () => {
return; return;
} }
setError('');
setScanMessage('');
setScanConflict(null); setScanConflict(null);
closeScanner(); closeScanner();
@ -1086,38 +1049,157 @@ const TimeClockPage = () => {
scanError.code === 409 && scanError.code === 409 &&
scanError.conflict scanError.conflict
) { ) {
const nowTime =
new Date()
.toTimeString()
.slice(0, 5);
setConflictTime(
scanError.scanTime ||
nowTime,
);
setScanConflict({ setScanConflict({
code, code,
type: type:
scanError.conflict, scanError.conflict,
message: message:
scanError.message, scanError.message,
resolution:
scanError.resolution,
missingTime:
scanError.missingTime,
scanTime:
scanError.scanTime,
step: 'confirm',
}); });
} else {
return;
}
setError( setError(
scanError.message, scanError.message,
); );
} }
}
}; };
const resolveScanConflict = const getScanConflictConfig = () => {
async ( if (!scanConflict) {
resolution, return null;
) => { }
if (!scanConflict) return;
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(''); setError('');
try { try {
const options = {
resolution:
config.resolution,
time: conflictTime,
};
if (
scanConflict.scanTime
) {
options.scan_time =
scanConflict.scanTime;
}
const response = const response =
await timeApi.scanTimeEntry( await timeApi.scanTimeEntry(
scanConflict.code, scanConflict.code,
{ options,
resolution,
time: conflictTime,
},
); );
setScanMessage( setScanMessage(
@ -1439,7 +1521,7 @@ const TimeClockPage = () => {
timeString, timeString,
) => { ) => {
if (!timeString) { if (!timeString) {
return '00h00'; return '────';
} }
const [ const [
@ -1447,10 +1529,6 @@ const TimeClockPage = () => {
minutes, minutes,
] = timeString.split(':'); ] = timeString.split(':');
if (hours === '24') {
return '23h59';
}
return `${String(hours).padStart( return `${String(hours).padStart(
2, 2,
'0', '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 = ( const formatDate = (
dateString, dateString,
) => { ) => {
@ -1587,7 +1605,16 @@ const TimeClockPage = () => {
========================================================= */ ========================================================= */
return ( return (
<Card className="flex h-full min-h-0 w-full flex-col"> /*
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
*/
<Card className="flex h-full min-h-0 w-full flex-col overflow-y-auto lg:overflow-hidden">
<CardHeader className="border-b"> <CardHeader className="border-b">
<CardTitle> <CardTitle>
@ -1657,57 +1684,101 @@ const TimeClockPage = () => {
<Card className="mb-6"> <Card className="mb-6">
<CardHeader className="border-b"> <CardHeader className="border-b">
<CardTitle> <CardTitle>
Conflit de pointage {scanConflict.step === 'confirm'
? 'Confirmation du pointage'
: 'Heure manquante'}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="space-y-4">
<p className="mb-2"> {(() => {
{scanConflict.message} const config =
getScanConflictConfig();
if (!config) {
return null;
}
if (
scanConflict.step ===
'confirm'
) {
return (
<>
<p>
{config.confirmationMessage}
</p> </p>
<Label htmlFor="conflict-time"> <div className="flex flex-wrap gap-2">
Heure à utiliser <Button
type="button"
onClick={
startScanConflictResolution
}
>
Oui, continuer
</Button>
<Button
type="button"
variant="secondary"
onClick={
cancelScanConflict
}
>
Non, annuler
</Button>
</div>
</>
);
}
return (
<>
<p>
{config.confirmationMessage}
</p>
<div>
<Label>
{config.timeLabel}
</Label> </Label>
<div className="mt-2">
<TimePicker <TimePicker
value={conflictTime} value={
conflictTime
}
onChange={ onChange={
setConflictTime setConflictTime
} }
/> />
<div className="mt-4 flex flex-wrap gap-2">
<Button
onClick={() =>
resolveScanConflict(
scanConflict.type ===
'open_entry'
? 'cancel_entry'
: 'cancel_exit',
)
}
variant="destructive"
>
Annuler le pointage
</Button>
<Button
onClick={() =>
resolveScanConflict(
scanConflict.type ===
'open_entry'
? 'complete_entry'
: 'complete_exit',
)
}
variant="default"
>
Renseigner l’heure
</Button>
</div> </div>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
onClick={
resolveScanConflict
}
>
Enregistrer
</Button>
<Button
type="button"
variant="secondary"
onClick={
cancelScanConflict
}
>
Annuler
</Button>
</div>
</>
);
})()}
</CardContent> </CardContent>
</Card> </Card>
)} )}
@ -1789,7 +1860,7 @@ const TimeClockPage = () => {
GESTION + HISTORIQUE GESTION + HISTORIQUE
====================================================== */} ====================================================== */}
<div className="grid w-full grid-cols-1 gap-6 lg:min-h-0 lg:flex-1 lg:grid-cols-2"> <div className="grid w-full min-h-0 grid-cols-1 gap-6 lg:flex-1 lg:grid-cols-2">
{/* ================================================= {/* =================================================
COLONNE GAUCHE COLONNE GAUCHE
@ -1932,6 +2003,43 @@ const TimeClockPage = () => {
> >
<div className="mb-4 grid grid-cols-1 gap-4"> <div className="mb-4 grid grid-cols-1 gap-4">
<div>
<Label>
Date d'entrée :
</Label>
<DatePicker
value={editForm.date}
onChange={(value) =>
setEditForm({
...editForm,
date: value,
end_date:
editForm.end_date &&
editForm.end_date < value
? value
: editForm.end_date,
})
}
/>
</div>
<div>
<Label>
Date de sortie :
</Label>
<DatePicker
value={editForm.end_date}
min={editForm.date}
onChange={(value) =>
setEditForm({
...editForm,
end_date: value,
})
}
/>
</div>
<div> <div>
<Label> <Label>
@ -1939,43 +2047,28 @@ const TimeClockPage = () => {
</Label> </Label>
<TimePicker <TimePicker
value={ value={editForm.start_time}
editForm.start_time onChange={(value) =>
} setEditForm({
onChange={(
value,
) =>
setEditForm(
{
...editForm, ...editForm,
start_time: start_time: value,
value, })
},
)
} }
/> />
</div> </div>
<div> <div>
<Label> <Label>
Heure de sortie : Heure de sortie :
</Label> </Label>
<TimePicker <TimePicker
value={ value={editForm.end_time}
editForm.end_time onChange={(value) =>
} setEditForm({
onChange={(
value,
) =>
setEditForm(
{
...editForm, ...editForm,
end_time: end_time: value,
value, })
},
)
} }
/> />
</div> </div>
@ -2143,11 +2236,20 @@ const TimeClockPage = () => {
HISTORIQUE HISTORIQUE
================================================== */} ================================================== */}
<div className="flex min-w-0 flex-col lg:h-full"> <div className="flex min-w-0 min-h-0 flex-col lg:h-full">
<Card className="flex h-full flex-col overflow-y-auto"> {/*
MOBILE :
max-h-[60vh] limite la hauteur de l'historique
pour permettre son propre scroll.
<CardHeader className="border-b"> DESKTOP :
lg:h-full permet à l'historique de remplir
toute la colonne disponible.
*/}
<Card className="flex h-auto max-h-[60vh] min-h-0 flex-col overflow-hidden lg:h-full lg:max-h-none">
<CardHeader className="shrink-0 border-b">
<CardTitle> <CardTitle>
Historique des pointages{' '} Historique des pointages{' '}
{!showAllUsers && {!showAllUsers &&
@ -2158,7 +2260,11 @@ const TimeClockPage = () => {
</CardHeader> </CardHeader>
<CardContent className="flex-1 overflow-y-auto"> {/*
C'est CE conteneur qui possède le scroll
de l'historique sur PC et mobile.
*/}
<CardContent className="min-h-0 flex-1 overflow-y-auto">
{loading ? ( {loading ? (
<p> <p>
@ -2203,6 +2309,10 @@ const TimeClockPage = () => {
Durée Durée
</TableHead> </TableHead>
<TableHead>
Statut
</TableHead>
{isAdmin && ( {isAdmin && (
<TableHead> <TableHead>
Actions Actions
@ -2262,14 +2372,37 @@ const TimeClockPage = () => {
)} )}
</TableCell> </TableCell>
<TableCell> <TableCell className="text-center">
{entry.duration !== null &&
entry.duration !== undefined ? (
<Badge variant="secondary"> <Badge variant="secondary">
{formatDuration( {formatDuration(
entry.duration, entry.duration,
)} )}
</Badge> </Badge>
) : (
<span className="text-sm text-muted-foreground">
────
</span>
)}
</TableCell> </TableCell>
<TableCell>
{entry.start_time &&
entry.end_time ? (
<Badge variant="secondary">
Complet
</Badge>
) : entry.start_time ? (
<Badge variant="default">
Entrée ouverte
</Badge>
) : (
<Badge variant="destructive">
Sortie sans entrée
</Badge>
)}
</TableCell>
{isAdmin && ( {isAdmin && (
<TableCell> <TableCell>