test
This commit is contained in:
parent
d7b42af4c0
commit
13769b12fa
@ -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):
|
||||||
|
|||||||
@ -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})
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@ -164,3 +164,22 @@ th {
|
|||||||
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;
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user