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):
|
class TimeEntry(models.Model):
|
||||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='time_entries')
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='time_entries')
|
||||||
date = models.DateField()
|
date = models.DateField()
|
||||||
start_time = models.TimeField()
|
start_time = models.TimeField(null=True, blank=True)
|
||||||
end_time = models.TimeField()
|
end_time = models.TimeField(null=True, blank=True)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
@ -24,7 +24,7 @@ class TimeEntry(models.Model):
|
|||||||
start = datetime.combine(self.date, self.start_time)
|
start = datetime.combine(self.date, self.start_time)
|
||||||
end = datetime.combine(self.date, self.end_time)
|
end = datetime.combine(self.date, self.end_time)
|
||||||
return (end - start).total_seconds() / 3600
|
return (end - start).total_seconds() / 3600
|
||||||
return 0
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def day_of_week(self):
|
def day_of_week(self):
|
||||||
|
|||||||
@ -16,11 +16,21 @@ class TimeEntryCreateSerializer(serializers.ModelSerializer):
|
|||||||
model = TimeEntry
|
model = TimeEntry
|
||||||
fields = ['date', 'start_time', 'end_time']
|
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 TimeEntryUpdateSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = TimeEntry
|
model = TimeEntry
|
||||||
fields = ['start_time', 'end_time']
|
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):
|
class WeeklySummarySerializer(serializers.Serializer):
|
||||||
user = serializers.StringRelatedField()
|
user = serializers.StringRelatedField()
|
||||||
username = serializers.CharField()
|
username = serializers.CharField()
|
||||||
|
|||||||
@ -2,6 +2,7 @@ from django.urls import path
|
|||||||
from .views import (
|
from .views import (
|
||||||
TimeEntryListView,
|
TimeEntryListView,
|
||||||
TimeEntryCreateView,
|
TimeEntryCreateView,
|
||||||
|
TimeEntryScanView,
|
||||||
TimeEntryDetailView,
|
TimeEntryDetailView,
|
||||||
TimeEntryUpdateView,
|
TimeEntryUpdateView,
|
||||||
WeeklySummaryView,
|
WeeklySummaryView,
|
||||||
@ -11,6 +12,7 @@ from .views import (
|
|||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('entries/', TimeEntryListView.as_view(), name='time-entry-list'),
|
path('entries/', TimeEntryListView.as_view(), name='time-entry-list'),
|
||||||
path('entries/create/', TimeEntryCreateView.as_view(), name='time-entry-create'),
|
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>/', TimeEntryDetailView.as_view(), name='time-entry-detail'),
|
||||||
path('entries/<int:entry_id>/update/', TimeEntryUpdateView.as_view(), name='time-entry-update'),
|
path('entries/<int:entry_id>/update/', TimeEntryUpdateView.as_view(), name='time-entry-update'),
|
||||||
path('weekly-summary/', UserWeeklySummaryView.as_view(), name='user-weekly-summary'),
|
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.permissions import IsAuthenticated
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
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 decouple import config
|
||||||
from .models import TimeEntry
|
from .models import TimeEntry
|
||||||
from .serializers import (
|
from .serializers import (
|
||||||
TimeEntrySerializer,
|
TimeEntrySerializer,
|
||||||
@ -31,6 +33,43 @@ class TimeEntryCreateView(generics.CreateAPIView):
|
|||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
serializer.save(user=self.request.user)
|
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):
|
class TimeEntryDetailView(generics.RetrieveUpdateDestroyAPIView):
|
||||||
serializer_class = TimeEntrySerializer
|
serializer_class = TimeEntrySerializer
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
@ -107,8 +146,8 @@ class WeeklySummaryView(APIView):
|
|||||||
days_data.append({
|
days_data.append({
|
||||||
'date': entry.date.isoformat(),
|
'date': entry.date.isoformat(),
|
||||||
'day': entry.date.strftime('%A'),
|
'day': entry.date.strftime('%A'),
|
||||||
'start_time': str(entry.start_time),
|
'start_time': str(entry.start_time) if entry.start_time else None,
|
||||||
'end_time': str(entry.end_time),
|
'end_time': str(entry.end_time) if entry.end_time else None,
|
||||||
'duration': entry.duration
|
'duration': entry.duration
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
@ -120,7 +159,7 @@ class WeeklySummaryView(APIView):
|
|||||||
'duration': 0
|
'duration': 0
|
||||||
})
|
})
|
||||||
|
|
||||||
total_hours = sum(entry.duration for entry in entries)
|
total_hours = sum(entry.duration or 0 for entry in entries)
|
||||||
|
|
||||||
summary = {
|
summary = {
|
||||||
'user': user.username,
|
'user': user.username,
|
||||||
@ -163,8 +202,8 @@ class UserWeeklySummaryView(APIView):
|
|||||||
days_data.append({
|
days_data.append({
|
||||||
'date': entry.date.isoformat(),
|
'date': entry.date.isoformat(),
|
||||||
'day': entry.date.strftime('%A'),
|
'day': entry.date.strftime('%A'),
|
||||||
'start_time': str(entry.start_time),
|
'start_time': str(entry.start_time) if entry.start_time else None,
|
||||||
'end_time': str(entry.end_time),
|
'end_time': str(entry.end_time) if entry.end_time else None,
|
||||||
'duration': entry.duration
|
'duration': entry.duration
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
@ -176,7 +215,7 @@ class UserWeeklySummaryView(APIView):
|
|||||||
'duration': 0
|
'duration': 0
|
||||||
})
|
})
|
||||||
|
|
||||||
total_hours = sum(entry.duration for entry in entries)
|
total_hours = sum(entry.duration or 0 for entry in entries)
|
||||||
|
|
||||||
summary = {
|
summary = {
|
||||||
'user': user.username,
|
'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
|
// Get a specific time entry
|
||||||
getTimeEntry: async (id) => {
|
getTimeEntry: async (id) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -12,6 +12,7 @@ const TimeClockPage = () => {
|
|||||||
const { isAdmin } = useAuth();
|
const { isAdmin } = useAuth();
|
||||||
const [isScanning, setIsScanning] = useState(false);
|
const [isScanning, setIsScanning] = useState(false);
|
||||||
const [scanResult, setScanResult] = useState('');
|
const [scanResult, setScanResult] = useState('');
|
||||||
|
const [scanMessage, setScanMessage] = useState('');
|
||||||
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([]);
|
||||||
@ -87,7 +88,7 @@ const TimeClockPage = () => {
|
|||||||
|
|
||||||
const handleUpdateEntry = async (e) => {
|
const handleUpdateEntry = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!editForm.start_time || !editForm.end_time) {
|
if (!editForm.start_time && !editForm.end_time) {
|
||||||
setError('Veuillez remplir tous les champs');
|
setError('Veuillez remplir tous les champs');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -141,6 +142,19 @@ const TimeClockPage = () => {
|
|||||||
setIsScanning(false);
|
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(() => () => stopScanner(), []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -156,8 +170,7 @@ const TimeClockPage = () => {
|
|||||||
videoRef.current,
|
videoRef.current,
|
||||||
(result) => {
|
(result) => {
|
||||||
if (result && !cancelled) {
|
if (result && !cancelled) {
|
||||||
setScanResult(result.getText());
|
handleQrResult(result.getText());
|
||||||
stopScanner();
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@ -195,6 +208,7 @@ const TimeClockPage = () => {
|
|||||||
const startScanner = async () => {
|
const startScanner = async () => {
|
||||||
setError('');
|
setError('');
|
||||||
setScanResult('');
|
setScanResult('');
|
||||||
|
setScanMessage('');
|
||||||
|
|
||||||
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.');
|
||||||
@ -256,6 +270,8 @@ const TimeClockPage = () => {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{scanMessage && <div className="alert alert-success">{scanMessage}</div>}
|
||||||
|
|
||||||
{/* Time tracking section */}
|
{/* Time tracking section */}
|
||||||
<div className="time-tracking-section">
|
<div className="time-tracking-section">
|
||||||
|
|
||||||
@ -276,7 +292,6 @@ const TimeClockPage = () => {
|
|||||||
type="time"
|
type="time"
|
||||||
value={editForm.start_time}
|
value={editForm.start_time}
|
||||||
onChange={(e) => setEditForm({ ...editForm, start_time: e.target.value })}
|
onChange={(e) => setEditForm({ ...editForm, start_time: e.target.value })}
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
@ -286,7 +301,6 @@ const TimeClockPage = () => {
|
|||||||
type="time"
|
type="time"
|
||||||
value={editForm.end_time}
|
value={editForm.end_time}
|
||||||
onChange={(e) => setEditForm({ ...editForm, end_time: e.target.value })}
|
onChange={(e) => setEditForm({ ...editForm, end_time: e.target.value })}
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="btn-group">
|
<div className="btn-group">
|
||||||
@ -369,8 +383,8 @@ const TimeClockPage = () => {
|
|||||||
<td>{entry.user}</td>
|
<td>{entry.user}</td>
|
||||||
}
|
}
|
||||||
<td>{formatDate(entry.date)}</td>
|
<td>{formatDate(entry.date)}</td>
|
||||||
<td>{formatTime(entry.start_time)}</td>
|
<td>{entry.start_time ? formatTime(entry.start_time) : 'Non renseignée'}</td>
|
||||||
<td>{formatTime(entry.end_time)}</td>
|
<td>{entry.end_time ? formatTime(entry.end_time) : 'Non renseignée'}</td>
|
||||||
<td>{formatDuration(entry.duration)}</td>
|
<td>{formatDuration(entry.duration)}</td>
|
||||||
{isAdmin &&
|
{isAdmin &&
|
||||||
<td>
|
<td>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user