nice
This commit is contained in:
parent
3b6d99c825
commit
a2fd47369d
@ -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),
|
||||
),
|
||||
]
|
||||
@ -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):
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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/<int:entry_id>/', TimeEntryDetailView.as_view(), name='time-entry-detail'),
|
||||
path('entries/<int:entry_id>/update/', TimeEntryUpdateView.as_view(), name='time-entry-update'),
|
||||
path('weekly-summary/', UserWeeklySummaryView.as_view(), name='user-weekly-summary'),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 = () => {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{scanMessage && <div className="alert alert-success">{scanMessage}</div>}
|
||||
|
||||
{/* Time tracking section */}
|
||||
<div className="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
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
@ -286,7 +301,6 @@ const TimeClockPage = () => {
|
||||
type="time"
|
||||
value={editForm.end_time}
|
||||
onChange={(e) => setEditForm({ ...editForm, end_time: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="btn-group">
|
||||
@ -369,8 +383,8 @@ const TimeClockPage = () => {
|
||||
<td>{entry.user}</td>
|
||||
}
|
||||
<td>{formatDate(entry.date)}</td>
|
||||
<td>{formatTime(entry.start_time)}</td>
|
||||
<td>{formatTime(entry.end_time)}</td>
|
||||
<td>{entry.start_time ? formatTime(entry.start_time) : 'Non renseignée'}</td>
|
||||
<td>{entry.end_time ? formatTime(entry.end_time) : 'Non renseignée'}</td>
|
||||
<td>{formatDuration(entry.duration)}</td>
|
||||
{isAdmin &&
|
||||
<td>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user