diff --git a/backend/time_tracking/migrations/0002_allow_incomplete_time_entries.py b/backend/time_tracking/migrations/0002_allow_incomplete_time_entries.py new file mode 100644 index 0000000..7ab74f5 --- /dev/null +++ b/backend/time_tracking/migrations/0002_allow_incomplete_time_entries.py @@ -0,0 +1,20 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('time_tracking', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='timeentry', + name='start_time', + field=models.TimeField(blank=True, null=True), + ), + migrations.AlterField( + model_name='timeentry', + name='end_time', + field=models.TimeField(blank=True, null=True), + ), + ] \ No newline at end of file diff --git a/backend/time_tracking/models.py b/backend/time_tracking/models.py index 29c8c00..7836b2d 100644 --- a/backend/time_tracking/models.py +++ b/backend/time_tracking/models.py @@ -5,8 +5,8 @@ from datetime import datetime, date class TimeEntry(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='time_entries') date = models.DateField() - start_time = models.TimeField() - end_time = models.TimeField() + 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) @@ -24,7 +24,7 @@ class TimeEntry(models.Model): start = datetime.combine(self.date, self.start_time) end = datetime.combine(self.date, self.end_time) return (end - start).total_seconds() / 3600 - return 0 + return None @property def day_of_week(self): diff --git a/backend/time_tracking/serializers.py b/backend/time_tracking/serializers.py index a6fd3af..70bcb6b 100644 --- a/backend/time_tracking/serializers.py +++ b/backend/time_tracking/serializers.py @@ -16,11 +16,21 @@ class TimeEntryCreateSerializer(serializers.ModelSerializer): model = TimeEntry fields = ['date', 'start_time', 'end_time'] + def validate(self, attrs): + if not attrs.get('start_time') and not attrs.get('end_time'): + raise serializers.ValidationError('Une heure d’entrée ou de sortie est requise.') + return attrs + class TimeEntryUpdateSerializer(serializers.ModelSerializer): class Meta: model = TimeEntry fields = ['start_time', 'end_time'] + def validate(self, attrs): + if not attrs.get('start_time') and not attrs.get('end_time'): + raise serializers.ValidationError('Une heure d’entrée ou de sortie est requise.') + return attrs + class WeeklySummarySerializer(serializers.Serializer): user = serializers.StringRelatedField() username = serializers.CharField() diff --git a/backend/time_tracking/urls.py b/backend/time_tracking/urls.py index 7cae966..42ac4d2 100644 --- a/backend/time_tracking/urls.py +++ b/backend/time_tracking/urls.py @@ -2,6 +2,7 @@ from django.urls import path from .views import ( TimeEntryListView, TimeEntryCreateView, + TimeEntryScanView, TimeEntryDetailView, TimeEntryUpdateView, WeeklySummaryView, @@ -11,6 +12,7 @@ from .views import ( urlpatterns = [ path('entries/', TimeEntryListView.as_view(), name='time-entry-list'), path('entries/create/', TimeEntryCreateView.as_view(), name='time-entry-create'), + path('entries/scan/', TimeEntryScanView.as_view(), name='time-entry-scan'), path('entries//', TimeEntryDetailView.as_view(), name='time-entry-detail'), path('entries//update/', TimeEntryUpdateView.as_view(), name='time-entry-update'), path('weekly-summary/', UserWeeklySummaryView.as_view(), name='user-weekly-summary'), diff --git a/backend/time_tracking/views.py b/backend/time_tracking/views.py index dddc4a2..6cedf9c 100644 --- a/backend/time_tracking/views.py +++ b/backend/time_tracking/views.py @@ -3,8 +3,10 @@ from rest_framework.response import Response from rest_framework.permissions import IsAuthenticated from rest_framework.views import APIView from django.contrib.auth.models import User +from django.db import transaction from django.utils import timezone from datetime import datetime, date, timedelta +from decouple import config from .models import TimeEntry from .serializers import ( TimeEntrySerializer, @@ -31,6 +33,43 @@ class TimeEntryCreateView(generics.CreateAPIView): def perform_create(self, serializer): serializer.save(user=self.request.user) +class TimeEntryScanView(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request): + code = request.data.get('code') + if code == config('SECRET_POINTAGE_ENTER', default=''): + action = 'enter' + elif code == config('SECRET_POINTAGE_EXIT', default=''): + action = 'exit' + else: + return Response({'error': 'QR code de pointage invalide.'}, status=status.HTTP_400_BAD_REQUEST) + + now = timezone.localtime() + with transaction.atomic(): + open_entry = TimeEntry.objects.select_for_update().filter( + user=request.user, + date=now.date(), + start_time__isnull=False, + end_time__isnull=True, + ).order_by('-start_time').first() + + if action == 'enter': + if open_entry: + return Response({'error': 'Une entrée est déjà ouverte pour aujourd’hui.'}, status=status.HTTP_400_BAD_REQUEST) + entry = TimeEntry.objects.create(user=request.user, date=now.date(), start_time=now.time()) + message = 'Entrée enregistrée.' + elif open_entry: + open_entry.end_time = now.time() + open_entry.save(update_fields=['end_time', 'updated_at']) + entry = open_entry + message = 'Sortie enregistrée.' + else: + entry = TimeEntry.objects.create(user=request.user, date=now.date(), end_time=now.time()) + message = 'Sortie enregistrée sans entrée correspondante.' + + return Response({'action': action, 'message': message, 'entry': TimeEntrySerializer(entry).data}) + class TimeEntryDetailView(generics.RetrieveUpdateDestroyAPIView): serializer_class = TimeEntrySerializer permission_classes = [IsAuthenticated] @@ -107,8 +146,8 @@ class WeeklySummaryView(APIView): days_data.append({ 'date': entry.date.isoformat(), 'day': entry.date.strftime('%A'), - 'start_time': str(entry.start_time), - 'end_time': str(entry.end_time), + 'start_time': str(entry.start_time) if entry.start_time else None, + 'end_time': str(entry.end_time) if entry.end_time else None, 'duration': entry.duration }) else: @@ -120,7 +159,7 @@ class WeeklySummaryView(APIView): 'duration': 0 }) - total_hours = sum(entry.duration for entry in entries) + total_hours = sum(entry.duration or 0 for entry in entries) summary = { 'user': user.username, @@ -163,8 +202,8 @@ class UserWeeklySummaryView(APIView): days_data.append({ 'date': entry.date.isoformat(), 'day': entry.date.strftime('%A'), - 'start_time': str(entry.start_time), - 'end_time': str(entry.end_time), + 'start_time': str(entry.start_time) if entry.start_time else None, + 'end_time': str(entry.end_time) if entry.end_time else None, 'duration': entry.duration }) else: @@ -176,7 +215,7 @@ class UserWeeklySummaryView(APIView): 'duration': 0 }) - total_hours = sum(entry.duration for entry in entries) + total_hours = sum(entry.duration or 0 for entry in entries) summary = { 'user': user.username, diff --git a/frontend/src/api/time.js b/frontend/src/api/time.js index bd892f9..206bec3 100644 --- a/frontend/src/api/time.js +++ b/frontend/src/api/time.js @@ -21,6 +21,15 @@ export const timeApi = { } }, + scanTimeEntry: async (code) => { + try { + const response = await api.post('/time/entries/scan/', { code }); + return response.data; + } catch (error) { + throw new Error(error.response?.data?.error || `Erreur lors du pointage: ${error.message}`); + } + }, + // Get a specific time entry getTimeEntry: async (id) => { try { diff --git a/frontend/src/pages/TimeClockPage.jsx b/frontend/src/pages/TimeClockPage.jsx index ab2befa..c638d31 100644 --- a/frontend/src/pages/TimeClockPage.jsx +++ b/frontend/src/pages/TimeClockPage.jsx @@ -12,6 +12,7 @@ const TimeClockPage = () => { const { isAdmin } = useAuth(); const [isScanning, setIsScanning] = useState(false); const [scanResult, setScanResult] = useState(''); + const [scanMessage, setScanMessage] = useState(''); const [error, setError] = useState(''); const [currentTime, setCurrentTime] = useState(new Date()); const [timeEntries, setTimeEntries] = useState([]); @@ -87,7 +88,7 @@ const TimeClockPage = () => { const handleUpdateEntry = async (e) => { e.preventDefault(); - if (!editForm.start_time || !editForm.end_time) { + if (!editForm.start_time && !editForm.end_time) { setError('Veuillez remplir tous les champs'); return; } @@ -141,6 +142,19 @@ const TimeClockPage = () => { setIsScanning(false); }; + const handleQrResult = async (code) => { + setScanResult(code); + stopScanner(); + + try { + const response = await timeApi.scanTimeEntry(code); + setScanMessage(response.message); + await loadTimeEntries(); + } catch (scanError) { + setError(scanError.message); + } + }; + useEffect(() => () => stopScanner(), []); useEffect(() => { @@ -156,8 +170,7 @@ const TimeClockPage = () => { videoRef.current, (result) => { if (result && !cancelled) { - setScanResult(result.getText()); - stopScanner(); + handleQrResult(result.getText()); } }, ); @@ -195,6 +208,7 @@ const TimeClockPage = () => { const startScanner = async () => { setError(''); setScanResult(''); + setScanMessage(''); if (!window.isSecureContext && window.location.hostname !== 'localhost' && window.location.hostname !== '127.0.0.1') { setError('La caméra nécessite une connexion HTTPS sur cette adresse réseau.'); @@ -256,6 +270,8 @@ const TimeClockPage = () => { )} + {scanMessage &&
{scanMessage}
} + {/* Time tracking section */}
@@ -276,7 +292,6 @@ const TimeClockPage = () => { type="time" value={editForm.start_time} onChange={(e) => setEditForm({ ...editForm, start_time: e.target.value })} - required />
@@ -286,7 +301,6 @@ const TimeClockPage = () => { type="time" value={editForm.end_time} onChange={(e) => setEditForm({ ...editForm, end_time: e.target.value })} - required />
@@ -369,8 +383,8 @@ const TimeClockPage = () => { {entry.user} } {formatDate(entry.date)} - {formatTime(entry.start_time)} - {formatTime(entry.end_time)} + {entry.start_time ? formatTime(entry.start_time) : 'Non renseignée'} + {entry.end_time ? formatTime(entry.end_time) : 'Non renseignée'} {formatDuration(entry.duration)} {isAdmin &&