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): def validate(self, attrs):
if not attrs.get('start_time') and not attrs.get('end_time'): 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.') 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 return attrs
class TimeEntryUpdateSerializer(serializers.ModelSerializer): class TimeEntryUpdateSerializer(serializers.ModelSerializer):
@ -29,6 +31,8 @@ class TimeEntryUpdateSerializer(serializers.ModelSerializer):
def validate(self, attrs): def validate(self, attrs):
if not attrs.get('start_time') and not attrs.get('end_time'): 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.') 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 return attrs
class WeeklySummarySerializer(serializers.Serializer): class WeeklySummarySerializer(serializers.Serializer):

View File

@ -6,6 +6,7 @@ from django.contrib.auth.models import User
from django.db import transaction from django.db import transaction
from django.utils import timezone from django.utils import timezone
from datetime import datetime, date, timedelta from datetime import datetime, date, timedelta
from datetime import time as datetime_time
from decouple import config from decouple import config
from .models import TimeEntry from .models import TimeEntry
from .serializers import ( from .serializers import (
@ -40,8 +41,8 @@ class TimeEntryScanView(APIView):
code = str(request.data.get('code') or '').strip().strip('"\'') code = str(request.data.get('code') or '').strip().strip('"\'')
if '=' in code: if '=' in code:
code = code.split('=', 1)[1].strip().strip('"\'') code = code.split('=', 1)[1].strip().strip('"\'')
enter_code = config('SECRET_POINTAGE_ENTER', default='').strip() enter_code = config('SECRET_POINTAGE_ENTER', default='').strip().strip('"\'')
exit_code = config('SECRET_POINTAGE_EXIT', default='').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:
@ -50,6 +51,16 @@ class TimeEntryScanView(APIView):
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)
now = timezone.localtime() 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(): with transaction.atomic():
open_entry = TimeEntry.objects.select_for_update().filter( open_entry = TimeEntry.objects.select_for_update().filter(
user=request.user, user=request.user,
@ -57,20 +68,64 @@ class TimeEntryScanView(APIView):
start_time__isnull=False, start_time__isnull=False,
end_time__isnull=True, end_time__isnull=True,
).order_by('-start_time').first() ).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 action == 'enter':
if open_entry: if open_entry:
return Response({'error': 'Une entrée est déjà ouverte pour aujourd’hui.'}, status=status.HTTP_400_BAD_REQUEST) if resolution == 'cancel_entry':
entry = TimeEntry.objects.create(user=request.user, date=now.date(), start_time=now.time()) 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.' message = 'Entrée enregistrée.'
elif open_entry: elif open_entry:
open_entry.end_time = now.time() open_entry.end_time = selected_time
open_entry.save(update_fields=['end_time', 'updated_at']) open_entry.save(update_fields=['end_time', 'updated_at'])
entry = open_entry entry = open_entry
message = 'Sortie enregistrée.' 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: else:
entry = TimeEntry.objects.create(user=request.user, date=now.date(), end_time=now.time()) previous_open_entry = TimeEntry.objects.select_for_update().filter(
message = 'Sortie enregistrée sans entrée correspondante.' 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}) 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 { try {
const response = await api.post('/time/entries/scan/', { code }); const response = await api.post('/time/entries/scan/', { code, ...options });
return response.data; return response.data;
} catch (error) { } 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

@ -163,4 +163,23 @@ th {
margin-top: 20px; margin-top: 20px;
padding: 15px; padding: 15px;
background-color: #e9ecef; 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 [isScanning, setIsScanning] = useState(false);
const [scanResult, setScanResult] = useState(''); const [scanResult, setScanResult] = useState('');
const [scanMessage, setScanMessage] = 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 [error, setError] = useState('');
const [currentTime, setCurrentTime] = useState(new Date()); const [currentTime, setCurrentTime] = useState(new Date());
const [timeEntries, setTimeEntries] = useState([]); const [timeEntries, setTimeEntries] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [newEntry, setNewEntry] = useState({ const [newEntry, setNewEntry] = useState({
date: new Date().toISOString().split('T')[0], date: new Date().toISOString().split('T')[0],
end_date: new Date().toISOString().split('T')[0],
start_time: '', start_time: '',
end_time: '' end_time: ''
}); });
@ -64,9 +67,21 @@ const TimeClockPage = () => {
} }
try { 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({ setNewEntry({
date: new Date().toISOString().split('T')[0], date: new Date().toISOString().split('T')[0],
end_date: new Date().toISOString().split('T')[0],
start_time: '', start_time: '',
end_time: '' end_time: ''
}); });
@ -144,6 +159,7 @@ const TimeClockPage = () => {
const handleQrResult = async (code) => { const handleQrResult = async (code) => {
setScanResult(code); setScanResult(code);
setScanConflict(null);
stopScanner(); stopScanner();
try { try {
@ -151,7 +167,27 @@ const TimeClockPage = () => {
setScanMessage(response.message); setScanMessage(response.message);
await loadTimeEntries(); await loadTimeEntries();
} catch (scanError) { } catch (scanError) {
setError(scanError.message); 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);
} }
}; };
@ -209,6 +245,7 @@ const TimeClockPage = () => {
setError(''); setError('');
setScanResult(''); setScanResult('');
setScanMessage(''); setScanMessage('');
setScanConflict(null);
if (!window.isSecureContext && window.location.hostname !== 'localhost' && window.location.hostname !== '127.0.0.1') { 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.'); 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>} {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 */} {/* Time tracking section */}
<div className="time-tracking-section"> <div className="time-tracking-section">
@ -329,6 +388,16 @@ const TimeClockPage = () => {
required required
/> />
</div> </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"> <div className="form-group">
<label>Heure d'entrée:</label> <label>Heure d'entrée:</label>
<input <input