test
This commit is contained in:
parent
d7b42af4c0
commit
13769b12fa
@ -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):
|
||||
|
||||
@ -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,20 +68,64 @@ 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())
|
||||
message = 'Sortie enregistrée sans entrée correspondante.'
|
||||
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})
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@ -163,4 +163,23 @@ th {
|
||||
margin-top: 20px;
|
||||
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;
|
||||
}
|
||||
@ -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,7 +167,27 @@ const TimeClockPage = () => {
|
||||
setScanMessage(response.message);
|
||||
await loadTimeEntries();
|
||||
} 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('');
|
||||
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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user