This commit is contained in:
Louis Mazin 2026-09-15 20:12:24 +02:00
parent d7b42af4c0
commit 13769b12fa
5 changed files with 162 additions and 12 deletions

View File

@ -19,6 +19,8 @@ class TimeEntryCreateSerializer(serializers.ModelSerializer):
def validate(self, attrs):
if not attrs.get('start_time') and not attrs.get('end_time'):
raise serializers.ValidationError('Une heure d’entrée ou de sortie est requise.')
if attrs.get('start_time') and attrs.get('end_time') and attrs['end_time'] <= attrs['start_time']:
raise serializers.ValidationError('L’heure de sortie doit être après l’heure d’entrée.')
return attrs
class TimeEntryUpdateSerializer(serializers.ModelSerializer):
@ -29,6 +31,8 @@ class TimeEntryUpdateSerializer(serializers.ModelSerializer):
def validate(self, attrs):
if not attrs.get('start_time') and not attrs.get('end_time'):
raise serializers.ValidationError('Une heure d’entrée ou de sortie est requise.')
if attrs.get('start_time') and attrs.get('end_time') and attrs['end_time'] <= attrs['start_time']:
raise serializers.ValidationError('L’heure de sortie doit être après l’heure d’entrée.')
return attrs
class WeeklySummarySerializer(serializers.Serializer):

View File

@ -6,6 +6,7 @@ from django.contrib.auth.models import User
from django.db import transaction
from django.utils import timezone
from datetime import datetime, date, timedelta
from datetime import time as datetime_time
from decouple import config
from .models import TimeEntry
from .serializers import (
@ -40,8 +41,8 @@ class TimeEntryScanView(APIView):
code = str(request.data.get('code') or '').strip().strip('"\'')
if '=' in code:
code = code.split('=', 1)[1].strip().strip('"\'')
enter_code = config('SECRET_POINTAGE_ENTER', default='').strip()
exit_code = config('SECRET_POINTAGE_EXIT', default='').strip()
enter_code = config('SECRET_POINTAGE_ENTER', default='').strip().strip('"\'')
exit_code = config('SECRET_POINTAGE_EXIT', default='').strip().strip('"\'')
if code == enter_code:
action = 'enter'
elif code == exit_code:
@ -50,6 +51,16 @@ class TimeEntryScanView(APIView):
return Response({'error': 'QR code de pointage invalide.'}, status=status.HTTP_400_BAD_REQUEST)
now = timezone.localtime()
requested_time = request.data.get('time')
if requested_time:
try:
selected_time = datetime_time.fromisoformat(str(requested_time)[:5])
except ValueError:
return Response({'error': 'Heure invalide.'}, status=status.HTTP_400_BAD_REQUEST)
else:
selected_time = now.time().replace(second=0, microsecond=0)
resolution = request.data.get('resolution')
with transaction.atomic():
open_entry = TimeEntry.objects.select_for_update().filter(
user=request.user,
@ -57,19 +68,63 @@ class TimeEntryScanView(APIView):
start_time__isnull=False,
end_time__isnull=True,
).order_by('-start_time').first()
open_exit = TimeEntry.objects.select_for_update().filter(
user=request.user,
date=now.date(),
start_time__isnull=True,
end_time__isnull=False,
).order_by('-end_time').first()
if action == 'enter':
if open_entry:
return Response({'error': 'Une entrée est déjà ouverte pour aujourd’hui.'}, status=status.HTTP_400_BAD_REQUEST)
entry = TimeEntry.objects.create(user=request.user, date=now.date(), start_time=now.time())
if resolution == 'cancel_entry':
open_entry.delete()
return Response({'action': action, 'message': 'Entrée annulée.'})
if resolution == 'complete_entry':
if selected_time <= open_entry.start_time:
return Response({'error': 'L’heure de sortie doit être après l’entrée.'}, status=status.HTTP_400_BAD_REQUEST)
open_entry.end_time = selected_time
open_entry.save(update_fields=['end_time', 'updated_at'])
return Response({'action': action, 'message': 'Sortie enregistrée pour l’entrée précédente.', 'entry': TimeEntrySerializer(open_entry).data})
return Response({'error': 'Une entrée est déjà ouverte.', 'conflict': 'open_entry', 'options': ['cancel_entry', 'complete_entry']}, status=status.HTTP_409_CONFLICT)
if open_exit:
if resolution == 'cancel_exit':
open_exit.delete()
return Response({'action': action, 'message': 'Sortie annulée.'})
if resolution == 'complete_exit':
open_exit.start_time = selected_time
open_exit.save(update_fields=['start_time', 'updated_at'])
return Response({'action': action, 'message': 'Entrée enregistrée pour la sortie précédente.', 'entry': TimeEntrySerializer(open_exit).data})
return Response({'error': 'Une sortie existe déjà.', 'conflict': 'open_exit', 'options': ['cancel_exit', 'complete_exit']}, status=status.HTTP_409_CONFLICT)
entry = TimeEntry.objects.create(user=request.user, date=now.date(), start_time=selected_time)
message = 'Entrée enregistrée.'
elif open_entry:
open_entry.end_time = now.time()
open_entry.end_time = selected_time
open_entry.save(update_fields=['end_time', 'updated_at'])
entry = open_entry
message = 'Sortie enregistrée.'
elif open_exit:
if resolution == 'cancel_exit':
open_exit.delete()
return Response({'action': action, 'message': 'Sortie annulée.'})
if resolution == 'complete_exit':
if selected_time >= open_exit.end_time:
return Response({'error': 'L’heure d’entrée doit être avant la sortie.'}, status=status.HTTP_400_BAD_REQUEST)
open_exit.start_time = selected_time
open_exit.save(update_fields=['start_time', 'updated_at'])
return Response({'action': action, 'message': 'Entrée enregistrée pour la sortie précédente.', 'entry': TimeEntrySerializer(open_exit).data})
return Response({'error': 'Une sortie existe déjà.', 'conflict': 'open_exit', 'options': ['cancel_exit', 'complete_exit']}, status=status.HTTP_409_CONFLICT)
else:
entry = TimeEntry.objects.create(user=request.user, date=now.date(), end_time=now.time())
previous_open_entry = TimeEntry.objects.select_for_update().filter(
user=request.user,
start_time__isnull=False,
end_time__isnull=True,
).exclude(date=now.date()).order_by('-date', '-start_time').first()
if previous_open_entry:
entry = TimeEntry.objects.create(user=request.user, date=now.date(), end_time=selected_time)
message = 'Sortie enregistrée. L’entrée est restée sur la journée précédente.'
else:
entry = TimeEntry.objects.create(user=request.user, date=now.date(), end_time=selected_time)
message = 'Sortie enregistrée sans entrée correspondante.'
return Response({'action': action, 'message': message, 'entry': TimeEntrySerializer(entry).data})

View File

@ -21,12 +21,15 @@ export const timeApi = {
}
},
scanTimeEntry: async (code) => {
scanTimeEntry: async (code, options = {}) => {
try {
const response = await api.post('/time/entries/scan/', { code });
const response = await api.post('/time/entries/scan/', { code, ...options });
return response.data;
} catch (error) {
throw new Error(error.response?.data?.error || `Erreur lors du pointage: ${error.message}`);
const scanError = new Error(error.response?.data?.error || `Erreur lors du pointage: ${error.message}`);
scanError.conflict = error.response?.data?.conflict;
scanError.code = error.response?.status;
throw scanError;
}
},

View File

@ -164,3 +164,22 @@ th {
padding: 15px;
background-color: #e9ecef;
}
.scan-conflict {
display: grid;
gap: 10px;
margin-top: 16px;
padding: 16px;
background: #fff8ed;
border: 1px solid #e7c58b;
border-radius: var(--radius-sm);
}
.scan-conflict p {
margin: 0;
color: var(--text-muted);
}
.scan-conflict input {
width: 150px;
}

View File

@ -13,12 +13,15 @@ const TimeClockPage = () => {
const [isScanning, setIsScanning] = useState(false);
const [scanResult, setScanResult] = useState('');
const [scanMessage, setScanMessage] = useState('');
const [scanConflict, setScanConflict] = useState(null);
const [conflictTime, setConflictTime] = useState(new Date().toTimeString().slice(0, 5));
const [error, setError] = useState('');
const [currentTime, setCurrentTime] = useState(new Date());
const [timeEntries, setTimeEntries] = useState([]);
const [loading, setLoading] = useState(true);
const [newEntry, setNewEntry] = useState({
date: new Date().toISOString().split('T')[0],
end_date: new Date().toISOString().split('T')[0],
start_time: '',
end_time: ''
});
@ -64,9 +67,21 @@ const TimeClockPage = () => {
}
try {
await timeApi.createTimeEntry(newEntry);
if (newEntry.date === newEntry.end_date) {
await timeApi.createTimeEntry({
date: newEntry.date,
start_time: newEntry.start_time,
end_time: newEntry.end_time,
});
} else {
await Promise.all([
timeApi.createTimeEntry({ date: newEntry.date, start_time: newEntry.start_time }),
timeApi.createTimeEntry({ date: newEntry.end_date, end_time: newEntry.end_time }),
]);
}
setNewEntry({
date: new Date().toISOString().split('T')[0],
end_date: new Date().toISOString().split('T')[0],
start_time: '',
end_time: ''
});
@ -144,6 +159,7 @@ const TimeClockPage = () => {
const handleQrResult = async (code) => {
setScanResult(code);
setScanConflict(null);
stopScanner();
try {
@ -151,8 +167,28 @@ const TimeClockPage = () => {
setScanMessage(response.message);
await loadTimeEntries();
} catch (scanError) {
if (scanError.code === 409 && scanError.conflict) {
setScanConflict({ code, type: scanError.conflict, message: scanError.message });
} else {
setError(scanError.message);
}
}
};
const resolveScanConflict = async (resolution) => {
if (!scanConflict) return;
setError('');
try {
const response = await timeApi.scanTimeEntry(scanConflict.code, {
resolution,
time: conflictTime,
});
setScanMessage(response.message);
setScanConflict(null);
await loadTimeEntries();
} catch (resolutionError) {
setError(resolutionError.message);
}
};
useEffect(() => () => stopScanner(), []);
@ -209,6 +245,7 @@ const TimeClockPage = () => {
setError('');
setScanResult('');
setScanMessage('');
setScanConflict(null);
if (!window.isSecureContext && window.location.hostname !== 'localhost' && window.location.hostname !== '127.0.0.1') {
setError('La caméra nécessite une connexion HTTPS sur cette adresse réseau.');
@ -272,6 +309,28 @@ const TimeClockPage = () => {
{scanMessage && <div className="alert alert-success">{scanMessage}</div>}
{scanConflict && (
<div className="scan-conflict" role="dialog" aria-live="polite">
<strong>{scanConflict.message}</strong>
<p>Choisissez quoi faire avec le pointage précédent :</p>
<label htmlFor="conflict-time">Heure à utiliser</label>
<input
id="conflict-time"
type="time"
value={conflictTime}
onChange={(event) => setConflictTime(event.target.value)}
/>
<div className="btn-group">
<button type="button" className="btn btn-danger" onClick={() => resolveScanConflict(scanConflict.type === 'open_entry' ? 'cancel_entry' : 'cancel_exit')}>
Annuler le pointage
</button>
<button type="button" className="btn btn-primary" onClick={() => resolveScanConflict(scanConflict.type === 'open_entry' ? 'complete_entry' : 'complete_exit')}>
Renseigner l’heure
</button>
</div>
</div>
)}
{/* Time tracking section */}
<div className="time-tracking-section">
@ -329,6 +388,16 @@ const TimeClockPage = () => {
required
/>
</div>
<div className="form-group">
<label>Date de sortie:</label>
<input
type="date"
value={newEntry.end_date}
min={newEntry.date}
onChange={(e) => setNewEntry({...newEntry, end_date: e.target.value})}
required
/>
</div>
<div className="form-group">
<label>Heure d'entrée:</label>
<input