temp
This commit is contained in:
parent
9c3b068646
commit
923f371e8b
18
backend/time_tracking/migrations/0003_timeentry_group_id.py
Normal file
18
backend/time_tracking/migrations/0003_timeentry_group_id.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@ -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
|
||||
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
|
||||
@ -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())
|
||||
entries = serializers.ListField(
|
||||
child=serializers.DictField(),
|
||||
)
|
||||
@ -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,
|
||||
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(
|
||||
|
||||
# =====================================================
|
||||
# RÉSOLUTION : entrée déjà ouverte
|
||||
# =====================================================
|
||||
|
||||
if resolution == 'complete_exit':
|
||||
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(
|
||||
)
|
||||
.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,
|
||||
date=now.date(),
|
||||
start_time__isnull=True,
|
||||
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':
|
||||
|
||||
# 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()
|
||||
)
|
||||
.exclude(
|
||||
date=today,
|
||||
)
|
||||
.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'])
|
||||
|
||||
current_date = last_date + timedelta(days=1)
|
||||
while current_date < now.date():
|
||||
group_id = (
|
||||
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(
|
||||
user=request.user,
|
||||
group_id=group_id,
|
||||
date=current_date,
|
||||
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)
|
||||
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.'
|
||||
current_date += timedelta(
|
||||
days=1,
|
||||
)
|
||||
|
||||
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
|
||||
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
|
||||
entry = TimeEntry.objects.get(
|
||||
id=entry_id,
|
||||
)
|
||||
|
||||
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:
|
||||
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(
|
||||
|
||||
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'))
|
||||
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({
|
||||
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,
|
||||
})
|
||||
'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,
|
||||
)
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -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
|
||||
@ -774,8 +779,44 @@ const TimeClockPage = () => {
|
||||
!newEntry.end_time
|
||||
) {
|
||||
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;
|
||||
}
|
||||
|
||||
@ -787,18 +828,20 @@ const TimeClockPage = () => {
|
||||
setError(
|
||||
'Veuillez sélectionner un utilisateur.',
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (
|
||||
newEntry.date ===
|
||||
newEntry.end_date
|
||||
) {
|
||||
await timeApi.createTimeEntry({
|
||||
date: newEntry.date,
|
||||
|
||||
end_date:
|
||||
newEntry.end_date,
|
||||
|
||||
start_time:
|
||||
newEntry.start_time,
|
||||
|
||||
end_time:
|
||||
newEntry.end_time,
|
||||
|
||||
@ -810,112 +853,25 @@ const TimeClockPage = () => {
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
} else {
|
||||
const dates = [];
|
||||
|
||||
const cursor = new Date(
|
||||
`${newEntry.date}T12:00:00`,
|
||||
);
|
||||
|
||||
const lastDate = new Date(
|
||||
`${newEntry.end_date}T12:00:00`,
|
||||
);
|
||||
|
||||
while (
|
||||
cursor <= lastDate
|
||||
) {
|
||||
dates.push(
|
||||
cursor
|
||||
const today =
|
||||
new Date()
|
||||
.toISOString()
|
||||
.slice(0, 10),
|
||||
);
|
||||
|
||||
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,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
.split('T')[0];
|
||||
|
||||
setNewEntry({
|
||||
date: new Date()
|
||||
.toISOString()
|
||||
.split('T')[0],
|
||||
|
||||
end_date: new Date()
|
||||
.toISOString()
|
||||
.split('T')[0],
|
||||
|
||||
date: today,
|
||||
end_date: today,
|
||||
start_time: '',
|
||||
end_time: '',
|
||||
});
|
||||
|
||||
loadTimeEntries();
|
||||
await loadTimeEntries();
|
||||
|
||||
setError('');
|
||||
} catch (err) {
|
||||
setError(
|
||||
err.message ||
|
||||
"Erreur lors de l'ajout du pointage",
|
||||
);
|
||||
|
||||
@ -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 || '',
|
||||
});
|
||||
};
|
||||
|
||||
@ -1064,6 +1025,8 @@ const TimeClockPage = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
setError('');
|
||||
setScanMessage('');
|
||||
setScanConflict(null);
|
||||
|
||||
closeScanner();
|
||||
@ -1086,38 +1049,157 @@ const TimeClockPage = () => {
|
||||
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',
|
||||
});
|
||||
} else {
|
||||
|
||||
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 (
|
||||
resolution,
|
||||
) => {
|
||||
if (!scanConflict) return;
|
||||
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,
|
||||
{
|
||||
resolution,
|
||||
time: conflictTime,
|
||||
},
|
||||
options,
|
||||
);
|
||||
|
||||
setScanMessage(
|
||||
@ -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 (
|
||||
<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">
|
||||
<CardTitle>
|
||||
@ -1657,57 +1684,101 @@ const TimeClockPage = () => {
|
||||
<Card className="mb-6">
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle>
|
||||
Conflit de pointage
|
||||
{scanConflict.step === 'confirm'
|
||||
? 'Confirmation du pointage'
|
||||
: 'Heure manquante'}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<p className="mb-2">
|
||||
{scanConflict.message}
|
||||
<CardContent className="space-y-4">
|
||||
{(() => {
|
||||
const config =
|
||||
getScanConflictConfig();
|
||||
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
scanConflict.step ===
|
||||
'confirm'
|
||||
) {
|
||||
return (
|
||||
<>
|
||||
<p>
|
||||
{config.confirmationMessage}
|
||||
</p>
|
||||
|
||||
<Label htmlFor="conflict-time">
|
||||
Heure à utiliser
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<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>
|
||||
|
||||
<div className="mt-2">
|
||||
<TimePicker
|
||||
value={conflictTime}
|
||||
value={
|
||||
conflictTime
|
||||
}
|
||||
onChange={
|
||||
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 className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={
|
||||
resolveScanConflict
|
||||
}
|
||||
>
|
||||
Enregistrer
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={
|
||||
cancelScanConflict
|
||||
}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
@ -1789,7 +1860,7 @@ const TimeClockPage = () => {
|
||||
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
|
||||
@ -1932,6 +2003,43 @@ const TimeClockPage = () => {
|
||||
>
|
||||
|
||||
<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>
|
||||
<Label>
|
||||
@ -1939,43 +2047,28 @@ const TimeClockPage = () => {
|
||||
</Label>
|
||||
|
||||
<TimePicker
|
||||
value={
|
||||
editForm.start_time
|
||||
}
|
||||
onChange={(
|
||||
value,
|
||||
) =>
|
||||
setEditForm(
|
||||
{
|
||||
value={editForm.start_time}
|
||||
onChange={(value) =>
|
||||
setEditForm({
|
||||
...editForm,
|
||||
start_time:
|
||||
value,
|
||||
},
|
||||
)
|
||||
start_time: value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<Label>
|
||||
Heure de sortie :
|
||||
</Label>
|
||||
|
||||
<TimePicker
|
||||
value={
|
||||
editForm.end_time
|
||||
}
|
||||
onChange={(
|
||||
value,
|
||||
) =>
|
||||
setEditForm(
|
||||
{
|
||||
value={editForm.end_time}
|
||||
onChange={(value) =>
|
||||
setEditForm({
|
||||
...editForm,
|
||||
end_time:
|
||||
value,
|
||||
},
|
||||
)
|
||||
end_time: value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@ -2143,11 +2236,20 @@ const TimeClockPage = () => {
|
||||
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>
|
||||
Historique des pointages{' '}
|
||||
{!showAllUsers &&
|
||||
@ -2158,7 +2260,11 @@ const TimeClockPage = () => {
|
||||
</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 ? (
|
||||
<p>
|
||||
@ -2203,6 +2309,10 @@ const TimeClockPage = () => {
|
||||
Durée
|
||||
</TableHead>
|
||||
|
||||
<TableHead>
|
||||
Statut
|
||||
</TableHead>
|
||||
|
||||
{isAdmin && (
|
||||
<TableHead>
|
||||
Actions
|
||||
@ -2262,14 +2372,37 @@ const TimeClockPage = () => {
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<TableCell className="text-center">
|
||||
{entry.duration !== null &&
|
||||
entry.duration !== undefined ? (
|
||||
<Badge variant="secondary">
|
||||
{formatDuration(
|
||||
entry.duration,
|
||||
)}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
────
|
||||
</span>
|
||||
)}
|
||||
</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 && (
|
||||
<TableCell>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user