clean
This commit is contained in:
parent
f4d4cdcc14
commit
1c2ed65db8
@ -1,33 +0,0 @@
|
|||||||
# Generated by Django 5.2.17 on 2026-09-12 17:26
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
initial = True
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='TimeEntry',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('date', models.DateField()),
|
|
||||||
('start_time', models.TimeField()),
|
|
||||||
('end_time', models.TimeField()),
|
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
|
||||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='time_entries', to=settings.AUTH_USER_MODEL)),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'ordering': ['-date', '-start_time'],
|
|
||||||
'unique_together': {('user', 'date', 'start_time')},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
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),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@ -6,7 +6,6 @@ from .views import (
|
|||||||
TimeEntryDetailView,
|
TimeEntryDetailView,
|
||||||
TimeEntryUpdateView,
|
TimeEntryUpdateView,
|
||||||
WeeklySummaryView,
|
WeeklySummaryView,
|
||||||
UserWeeklySummaryView
|
|
||||||
)
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
@ -15,6 +14,6 @@ urlpatterns = [
|
|||||||
path('entries/scan/', TimeEntryScanView.as_view(), name='time-entry-scan'),
|
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/', WeeklySummaryView.as_view(), name='user-weekly-summary'),
|
||||||
path('weekly-summary/<int:user_id>/', WeeklySummaryView.as_view(), name='weekly-summary'),
|
path('weekly-summary/<int:user_id>/', WeeklySummaryView.as_view(), name='weekly-summary'),
|
||||||
]
|
]
|
||||||
@ -5,7 +5,7 @@ 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.db import transaction
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from datetime import datetime, date, timedelta
|
from datetime import timedelta
|
||||||
from datetime import time as datetime_time
|
from datetime import time as datetime_time
|
||||||
from decouple import config
|
from decouple import config
|
||||||
from .models import TimeEntry
|
from .models import TimeEntry
|
||||||
@ -179,124 +179,48 @@ class TimeEntryUpdateView(APIView):
|
|||||||
status=status.HTTP_404_NOT_FOUND
|
status=status.HTTP_404_NOT_FOUND
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def build_weekly_summary(user):
|
||||||
|
today = timezone.localdate()
|
||||||
|
start_of_week = today - timedelta(days=today.weekday())
|
||||||
|
end_of_week = start_of_week + timedelta(days=6)
|
||||||
|
entries = list(TimeEntry.objects.filter(
|
||||||
|
user=user,
|
||||||
|
date__range=(start_of_week, end_of_week),
|
||||||
|
).order_by('date'))
|
||||||
|
entries_by_date = {}
|
||||||
|
for entry in entries:
|
||||||
|
entries_by_date.setdefault(entry.date, entry)
|
||||||
|
|
||||||
|
days_data = []
|
||||||
|
for day_offset in range(7):
|
||||||
|
current_day = start_of_week + timedelta(days=day_offset)
|
||||||
|
entry = entries_by_date.get(current_day)
|
||||||
|
days_data.append({
|
||||||
|
'date': current_day.isoformat(),
|
||||||
|
'day': current_day.strftime('%A'),
|
||||||
|
'start_time': str(entry.start_time) if entry and entry.start_time else None,
|
||||||
|
'end_time': str(entry.end_time) if entry and entry.end_time else None,
|
||||||
|
'duration': entry.duration if entry else 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
'user': user.username,
|
||||||
|
'username': user.username,
|
||||||
|
'total_hours': round(sum(entry.duration or 0 for entry in entries), 2),
|
||||||
|
'days': days_data,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class WeeklySummaryView(APIView):
|
class WeeklySummaryView(APIView):
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
def get(self, request, user_id=None):
|
def get(self, request, user_id=None):
|
||||||
# If user_id is provided and user is staff, get that user's summary
|
user = request.user
|
||||||
# Otherwise, get current user's summary
|
|
||||||
if user_id and (request.user.is_staff or request.user.is_superuser):
|
if user_id and (request.user.is_staff or request.user.is_superuser):
|
||||||
try:
|
try:
|
||||||
user = User.objects.get(id=user_id)
|
user = User.objects.get(id=user_id)
|
||||||
except User.DoesNotExist:
|
except User.DoesNotExist:
|
||||||
return Response(
|
return Response({'error': 'Utilisateur non trouvé'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
{'error': 'Utilisateur non trouvé'},
|
|
||||||
status=status.HTTP_404_NOT_FOUND
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
user = request.user
|
|
||||||
|
|
||||||
# Get current week (Monday to Sunday)
|
serializer = WeeklySummarySerializer(build_weekly_summary(user))
|
||||||
today = timezone.now().date()
|
|
||||||
start_of_week = today - timedelta(days=today.weekday())
|
|
||||||
end_of_week = start_of_week + timedelta(days=6)
|
|
||||||
|
|
||||||
# Get all time entries for the current week for this user
|
|
||||||
entries = TimeEntry.objects.filter(
|
|
||||||
user=user,
|
|
||||||
date__gte=start_of_week,
|
|
||||||
date__lte=end_of_week
|
|
||||||
).order_by('date')
|
|
||||||
|
|
||||||
# Group entries by day
|
|
||||||
days_data = []
|
|
||||||
for i in range(7):
|
|
||||||
current_day = start_of_week + timedelta(days=i)
|
|
||||||
day_entries = entries.filter(date=current_day)
|
|
||||||
|
|
||||||
if day_entries.exists():
|
|
||||||
# Get the first entry for this day (assuming one entry per day)
|
|
||||||
entry = day_entries.first()
|
|
||||||
days_data.append({
|
|
||||||
'date': entry.date.isoformat(),
|
|
||||||
'day': entry.date.strftime('%A'),
|
|
||||||
'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:
|
|
||||||
days_data.append({
|
|
||||||
'date': current_day.isoformat(),
|
|
||||||
'day': current_day.strftime('%A'),
|
|
||||||
'start_time': None,
|
|
||||||
'end_time': None,
|
|
||||||
'duration': 0
|
|
||||||
})
|
|
||||||
|
|
||||||
total_hours = sum(entry.duration or 0 for entry in entries)
|
|
||||||
|
|
||||||
summary = {
|
|
||||||
'user': user.username,
|
|
||||||
'username': user.username,
|
|
||||||
'total_hours': round(total_hours, 2),
|
|
||||||
'days': days_data
|
|
||||||
}
|
|
||||||
|
|
||||||
serializer = WeeklySummarySerializer(summary)
|
|
||||||
return Response(serializer.data)
|
|
||||||
|
|
||||||
class UserWeeklySummaryView(APIView):
|
|
||||||
permission_classes = [IsAuthenticated]
|
|
||||||
|
|
||||||
def get(self, request):
|
|
||||||
# Get current user's weekly summary
|
|
||||||
user = request.user
|
|
||||||
|
|
||||||
# Get current week (Monday to Sunday)
|
|
||||||
today = timezone.now().date()
|
|
||||||
start_of_week = today - timedelta(days=today.weekday())
|
|
||||||
end_of_week = start_of_week + timedelta(days=6)
|
|
||||||
|
|
||||||
# Get all time entries for the current week for this user
|
|
||||||
entries = TimeEntry.objects.filter(
|
|
||||||
user=user,
|
|
||||||
date__gte=start_of_week,
|
|
||||||
date__lte=end_of_week
|
|
||||||
).order_by('date')
|
|
||||||
|
|
||||||
# Group entries by day
|
|
||||||
days_data = []
|
|
||||||
for i in range(7):
|
|
||||||
current_day = start_of_week + timedelta(days=i)
|
|
||||||
day_entries = entries.filter(date=current_day)
|
|
||||||
|
|
||||||
if day_entries.exists():
|
|
||||||
# Get the first entry for this day (assuming one entry per day)
|
|
||||||
entry = day_entries.first()
|
|
||||||
days_data.append({
|
|
||||||
'date': entry.date.isoformat(),
|
|
||||||
'day': entry.date.strftime('%A'),
|
|
||||||
'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:
|
|
||||||
days_data.append({
|
|
||||||
'date': current_day.isoformat(),
|
|
||||||
'day': current_day.strftime('%A'),
|
|
||||||
'start_time': None,
|
|
||||||
'end_time': None,
|
|
||||||
'duration': 0
|
|
||||||
})
|
|
||||||
|
|
||||||
total_hours = sum(entry.duration or 0 for entry in entries)
|
|
||||||
|
|
||||||
summary = {
|
|
||||||
'user': user.username,
|
|
||||||
'username': user.username,
|
|
||||||
'total_hours': round(total_hours, 2),
|
|
||||||
'days': days_data
|
|
||||||
}
|
|
||||||
|
|
||||||
serializer = WeeklySummarySerializer(summary)
|
|
||||||
return Response(serializer.data)
|
return Response(serializer.data)
|
||||||
|
|||||||
@ -1,26 +0,0 @@
|
|||||||
from django.conf import settings
|
|
||||||
from django.db import migrations, models
|
|
||||||
import django.db.models.deletion
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
initial = True
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='Invitation',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('token', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
|
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
||||||
('expires_at', models.DateTimeField()),
|
|
||||||
('used_at', models.DateTimeField(blank=True, null=True)),
|
|
||||||
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='created_invitations', to=settings.AUTH_USER_MODEL)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@ -11,36 +11,6 @@ export const userApi = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get a specific user by ID
|
|
||||||
getUserById: async (id) => {
|
|
||||||
try {
|
|
||||||
const response = await api.get(`/users/${id}/`);
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error('Failed to fetch user: ' + error.message);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Create a new user
|
|
||||||
createUser: async (userData) => {
|
|
||||||
try {
|
|
||||||
const response = await api.post('/users/', userData);
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error('Failed to create user: ' + error.message);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Update a user
|
|
||||||
updateUser: async (id, userData) => {
|
|
||||||
try {
|
|
||||||
const response = await api.put(`/users/${id}/`, userData);
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error('Failed to update user: ' + error.message);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Delete a user
|
// Delete a user
|
||||||
deleteUser: async (id) => {
|
deleteUser: async (id) => {
|
||||||
try {
|
try {
|
||||||
@ -62,13 +32,12 @@ export const userApi = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get user roles
|
createInvitation: async () => {
|
||||||
getUserRoles: async () => {
|
|
||||||
try {
|
try {
|
||||||
const response = await api.get('/users/roles/');
|
const response = await api.post('/users/invitations/');
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error('Failed to fetch user roles: ' + error.message);
|
throw new Error('Failed to create invitation: ' + error.message);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Navigate, useLocation } from 'react-router-dom';
|
import { Navigate, useLocation } from 'react-router-dom';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/useAuth';
|
||||||
|
|
||||||
const AdminRoute = ({ children }) => {
|
const AdminRoute = ({ children }) => {
|
||||||
const { user, loading, isAdmin } = useAuth();
|
const { user, loading, isAdmin } = useAuth();
|
||||||
|
|||||||
@ -1,69 +0,0 @@
|
|||||||
.attendance-app {
|
|
||||||
min-height: 100vh;
|
|
||||||
background-color: #f0f2f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header {
|
|
||||||
background: #007bff;
|
|
||||||
color: white;
|
|
||||||
padding: 1rem 2rem;
|
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header h1 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1.8em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-nav {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-button {
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
padding: 10px 20px;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.3s;
|
|
||||||
font-size: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-button:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-button.active {
|
|
||||||
background: white;
|
|
||||||
color: #007bff;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-main {
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.app-header {
|
|
||||||
padding: 1rem;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-nav {
|
|
||||||
width: 100%;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-button {
|
|
||||||
flex: 1;
|
|
||||||
margin: 5px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import RecapTab from './RecapTab';
|
|
||||||
import './AttendanceApp.css';
|
|
||||||
|
|
||||||
const AttendanceApp = () => {
|
|
||||||
const [activeTab, setActiveTab] = useState('recap');
|
|
||||||
const [userId] = useState(1); // ID utilisateur simulé
|
|
||||||
|
|
||||||
const renderTabContent = () => {
|
|
||||||
switch (activeTab) {
|
|
||||||
case 'recap':
|
|
||||||
return <RecapTab userId={userId} />;
|
|
||||||
default:
|
|
||||||
return <RecapTab userId={userId} />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="attendance-app">
|
|
||||||
<header className="app-header">
|
|
||||||
<h1>Système de Pointage</h1>
|
|
||||||
<nav className="app-nav">
|
|
||||||
<button
|
|
||||||
className={`nav-button ${activeTab === 'recap' ? 'active' : ''}`}
|
|
||||||
onClick={() => setActiveTab('recap')}
|
|
||||||
>
|
|
||||||
Récapitulatif
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main className="app-main">
|
|
||||||
{renderTabContent()}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AttendanceApp;
|
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/useAuth';
|
||||||
import './Header.css';
|
import './Header.css';
|
||||||
|
|
||||||
const Header = () => {
|
const Header = () => {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Navigate, useLocation } from 'react-router-dom';
|
import { Navigate, useLocation } from 'react-router-dom';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/useAuth';
|
||||||
|
|
||||||
const ProtectedRoute = ({ children }) => {
|
const ProtectedRoute = ({ children }) => {
|
||||||
const { user, loading } = useAuth();
|
const { user, loading } = useAuth();
|
||||||
|
|||||||
@ -1,68 +0,0 @@
|
|||||||
.recap-tab {
|
|
||||||
padding: 20px;
|
|
||||||
background-color: #f5f5f5;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recap-tab.loading {
|
|
||||||
text-align: center;
|
|
||||||
padding: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-info {
|
|
||||||
background: white;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
|
||||||
padding: 20px;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-info h1 {
|
|
||||||
color: #333;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
font-size: 2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-details {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
|
||||||
gap: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-item {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
font-weight: bold;
|
|
||||||
color: #555;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.value {
|
|
||||||
color: #333;
|
|
||||||
font-size: 1.1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-section {
|
|
||||||
background: white;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.recap-tab {
|
|
||||||
padding: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-info h1 {
|
|
||||||
font-size: 1.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-details {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,56 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import WeeklySchedule from './WeeklySchedule';
|
|
||||||
import './RecapTab.css';
|
|
||||||
|
|
||||||
const RecapTab = ({ userId }) => {
|
|
||||||
const [user, setUser] = useState(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
// Simuler le chargement des données utilisateur
|
|
||||||
useEffect(() => {
|
|
||||||
// Ici, nous utiliserions un service pour charger les données utilisateur
|
|
||||||
// Pour l'exemple, on simule avec des données statiques
|
|
||||||
const mockUser = {
|
|
||||||
id: userId,
|
|
||||||
name: 'Jean Dupont',
|
|
||||||
email: 'jean.dupont@example.com',
|
|
||||||
department: 'Développement',
|
|
||||||
position: 'Développeur'
|
|
||||||
};
|
|
||||||
|
|
||||||
setUser(mockUser);
|
|
||||||
setLoading(false);
|
|
||||||
}, [userId]);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <div className="recap-tab loading">Chargement...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="recap-tab">
|
|
||||||
<div className="user-info">
|
|
||||||
<h1>Récapitulatif de {user.name}</h1>
|
|
||||||
<div className="user-details">
|
|
||||||
<div className="detail-item">
|
|
||||||
<span className="label">Email:</span>
|
|
||||||
<span className="value">{user.email}</span>
|
|
||||||
</div>
|
|
||||||
<div className="detail-item">
|
|
||||||
<span className="label">Département:</span>
|
|
||||||
<span className="value">{user.department}</span>
|
|
||||||
</div>
|
|
||||||
<div className="detail-item">
|
|
||||||
<span className="label">Poste:</span>
|
|
||||||
<span className="value">{user.position}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="schedule-section">
|
|
||||||
<WeeklySchedule userId={userId} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default RecapTab;
|
|
||||||
@ -1,303 +0,0 @@
|
|||||||
.time-clock-qr {
|
|
||||||
max-width: 600px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 20px;
|
|
||||||
background: white;
|
|
||||||
border-radius: 10px;
|
|
||||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.time-clock-qr h2 {
|
|
||||||
text-align: center;
|
|
||||||
color: #333;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scan-area {
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qr-placeholder {
|
|
||||||
width: 100%;
|
|
||||||
height: 250px;
|
|
||||||
border: 2px dashed #ccc;
|
|
||||||
border-radius: 10px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: #f9f9f9;
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qr-placeholder-content {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qr-code {
|
|
||||||
width: 150px;
|
|
||||||
height: 150px;
|
|
||||||
background: #333;
|
|
||||||
margin: 0 auto 15px;
|
|
||||||
border-radius: 8px;
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qr-code::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 20%;
|
|
||||||
left: 20%;
|
|
||||||
width: 60%;
|
|
||||||
height: 60%;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qr-code::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background: linear-gradient(45deg, #333 25%, transparent 25%, transparent 75%, #333 75%),
|
|
||||||
linear-gradient(45deg, #333 25%, transparent 25%, transparent 75%, #333 75%);
|
|
||||||
background-size: 20px 20px;
|
|
||||||
background-position: 0 0, 10px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scanning {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scanner-line {
|
|
||||||
width: 100%;
|
|
||||||
height: 2px;
|
|
||||||
background: #007bff;
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
animation: scan 2s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes scan {
|
|
||||||
0% { transform: translateY(-50%) translateY(-50%); }
|
|
||||||
50% { transform: translateY(-50%) translateY(50%); }
|
|
||||||
100% { transform: translateY(-50%) translateY(-50%); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.scan-result {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-icon {
|
|
||||||
font-size: 48px;
|
|
||||||
color: #28a745;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-selection {
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-selection h3 {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-buttons {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 20px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn {
|
|
||||||
padding: 20px 30px;
|
|
||||||
border: 2px solid #ddd;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: white;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
min-width: 120px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn:hover {
|
|
||||||
border-color: #007bff;
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 4px 8px rgba(0, 123, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn.selected {
|
|
||||||
border-color: #007bff;
|
|
||||||
background: #007bff;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-icon {
|
|
||||||
font-size: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-text {
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scan-button {
|
|
||||||
width: 100%;
|
|
||||||
padding: 15px;
|
|
||||||
background: #007bff;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scan-button:hover:not(:disabled) {
|
|
||||||
background: #0056b3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scan-button:disabled {
|
|
||||||
background: #ccc;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message {
|
|
||||||
margin-top: 20px;
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 8px;
|
|
||||||
text-align: center;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message.success {
|
|
||||||
background: #d4edda;
|
|
||||||
color: #155724;
|
|
||||||
border: 1px solid #c3e6cb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message.error {
|
|
||||||
background: #f8d7da;
|
|
||||||
color: #721c24;
|
|
||||||
border: 1px solid #f5c6cb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-message {
|
|
||||||
margin-top: 15px;
|
|
||||||
padding: 10px;
|
|
||||||
background: #f8d7da;
|
|
||||||
color: #721c24;
|
|
||||||
border-radius: 5px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Modal styles */
|
|
||||||
.modal-overlay {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-content {
|
|
||||||
background: white;
|
|
||||||
padding: 30px;
|
|
||||||
border-radius: 10px;
|
|
||||||
max-width: 400px;
|
|
||||||
width: 90%;
|
|
||||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-content h3 {
|
|
||||||
margin-top: 0;
|
|
||||||
color: #333;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-content p {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
text-align: center;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group label {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 10px;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
border-radius: 5px;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-buttons {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-cancel {
|
|
||||||
padding: 10px 20px;
|
|
||||||
background: #6c757d;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 5px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-confirm {
|
|
||||||
padding: 10px 20px;
|
|
||||||
background: #28a745;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 5px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.time-clock-qr {
|
|
||||||
margin: 10px;
|
|
||||||
padding: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-buttons {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qr-placeholder {
|
|
||||||
height: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-content {
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,203 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import './TimeClockQR.css';
|
|
||||||
import { useAuth } from '../context/AuthContext';
|
|
||||||
import api from '../api/axios';
|
|
||||||
|
|
||||||
const TimeClockQR = () => {
|
|
||||||
const { user } = useAuth();
|
|
||||||
const [scanResult, setScanResult] = useState(null);
|
|
||||||
const [isScanning, setIsScanning] = useState(false);
|
|
||||||
const [selectedAction, setSelectedAction] = useState('');
|
|
||||||
const [entryTime, setEntryTime] = useState('');
|
|
||||||
const [showEntryTimeModal, setShowEntryTimeModal] = useState(false);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [message, setMessage] = useState('');
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
|
|
||||||
// Simuler le scan QR (dans une vraie application, cela serait avec une librairie de scan)
|
|
||||||
const simulateQRScan = () => {
|
|
||||||
setIsScanning(true);
|
|
||||||
setMessage('Scan en cours...');
|
|
||||||
|
|
||||||
// Simuler un scan QR après un délai
|
|
||||||
setTimeout(() => {
|
|
||||||
const mockScanResult = {
|
|
||||||
userId: user?.id || 1,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
action: selectedAction
|
|
||||||
};
|
|
||||||
setScanResult(mockScanResult);
|
|
||||||
setIsScanning(false);
|
|
||||||
setMessage('Scan réussi !');
|
|
||||||
|
|
||||||
// Si c'est une sortie sans entrée, demander l'heure d'entrée
|
|
||||||
if (selectedAction === 'exit' && !hasEntryToday()) {
|
|
||||||
setShowEntryTimeModal(true);
|
|
||||||
} else {
|
|
||||||
processTimeEntry(mockScanResult);
|
|
||||||
}
|
|
||||||
}, 1500);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Vérifier si l'utilisateur a déjà pointé une entrée aujourd'hui
|
|
||||||
const hasEntryToday = () => {
|
|
||||||
// Dans une vraie application, cela viendrait de l'API
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Traiter l'entrée de temps
|
|
||||||
const processTimeEntry = async (scanData) => {
|
|
||||||
setLoading(true);
|
|
||||||
setError('');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const entryData = {
|
|
||||||
user: scanData.userId,
|
|
||||||
action: scanData.action,
|
|
||||||
timestamp: scanData.timestamp,
|
|
||||||
entry_time: selectedAction === 'exit' ? entryTime : null
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await api.post('/time/entries/', entryData);
|
|
||||||
|
|
||||||
setMessage(`Pointage ${scanData.action} enregistré avec succès !`);
|
|
||||||
setSelectedAction('');
|
|
||||||
setEntryTime('');
|
|
||||||
|
|
||||||
// Réinitialiser le scan
|
|
||||||
setTimeout(() => {
|
|
||||||
setScanResult(null);
|
|
||||||
setMessage('');
|
|
||||||
}, 3000);
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
setError('Erreur lors de l\'enregistrement du pointage');
|
|
||||||
console.error('Erreur de pointage:', err);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Gérer la soumission du formulaire d'heure d'entrée
|
|
||||||
const handleEntryTimeSubmit = (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (entryTime) {
|
|
||||||
setShowEntryTimeModal(false);
|
|
||||||
const scanData = {
|
|
||||||
userId: user?.id || 1,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
action: 'exit'
|
|
||||||
};
|
|
||||||
processTimeEntry(scanData);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="time-clock-qr">
|
|
||||||
<div className="qr-scan-container">
|
|
||||||
<h2>Pointage par QR Code</h2>
|
|
||||||
|
|
||||||
<div className="scan-area">
|
|
||||||
<div className="qr-placeholder">
|
|
||||||
{isScanning ? (
|
|
||||||
<div className="scanning">
|
|
||||||
<div className="scanner-line"></div>
|
|
||||||
<p>Scan en cours...</p>
|
|
||||||
</div>
|
|
||||||
) : scanResult ? (
|
|
||||||
<div className="scan-result">
|
|
||||||
<div className="result-icon">✓</div>
|
|
||||||
<p>Scan réussi !</p>
|
|
||||||
<p>Action: {scanResult.action}</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="qr-placeholder-content">
|
|
||||||
<div className="qr-code"></div>
|
|
||||||
<p>Positionnez le QR code dans le cadre</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="action-selection">
|
|
||||||
<h3>Sélectionnez votre action</h3>
|
|
||||||
<div className="action-buttons">
|
|
||||||
<button
|
|
||||||
className={`action-btn ${selectedAction === 'entry' ? 'selected' : ''}`}
|
|
||||||
onClick={() => setSelectedAction('entry')}
|
|
||||||
>
|
|
||||||
<span className="action-icon">▶</span>
|
|
||||||
<span className="action-text">Entrée</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`action-btn ${selectedAction === 'exit' ? 'selected' : ''}`}
|
|
||||||
onClick={() => setSelectedAction('exit')}
|
|
||||||
>
|
|
||||||
<span className="action-icon">⏹</span>
|
|
||||||
<span className="action-text">Sortie</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
className="scan-button"
|
|
||||||
onClick={simulateQRScan}
|
|
||||||
disabled={!selectedAction || isScanning || loading}
|
|
||||||
>
|
|
||||||
{isScanning ? 'Scannage...' : 'Scanner le QR Code'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{message && (
|
|
||||||
<div className={`message ${error ? 'error' : 'success'}`}>
|
|
||||||
{message}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="error-message">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Modal pour saisir l'heure d'entrée */}
|
|
||||||
{showEntryTimeModal && (
|
|
||||||
<div className="modal-overlay">
|
|
||||||
<div className="modal-content">
|
|
||||||
<h3>Heure d'entrée requise</h3>
|
|
||||||
<p>Vous avez indiqué une sortie sans avoir pointé l'entrée.</p>
|
|
||||||
<form onSubmit={handleEntryTimeSubmit}>
|
|
||||||
<div className="form-group">
|
|
||||||
<label htmlFor="entryTime">Heure d'entrée :</label>
|
|
||||||
<input
|
|
||||||
type="time"
|
|
||||||
id="entryTime"
|
|
||||||
value={entryTime}
|
|
||||||
onChange={(e) => setEntryTime(e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="modal-buttons">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn-cancel"
|
|
||||||
onClick={() => setShowEntryTimeModal(false)}
|
|
||||||
>
|
|
||||||
Annuler
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="btn-confirm"
|
|
||||||
>
|
|
||||||
Valider
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TimeClockQR;
|
|
||||||
@ -1,212 +0,0 @@
|
|||||||
.weekly-schedule { background: #fff; border: 1px solid #e7e3dc; border-radius: 14px; padding: 24px; margin: 0; color: #2b2926; }
|
|
||||||
.weekly-schedule {
|
|
||||||
margin: 0;
|
|
||||||
padding: 24px;
|
|
||||||
color: var(--text);
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.weekly-schedule.loading,
|
|
||||||
.weekly-schedule.schedule-error {
|
|
||||||
padding: 40px 24px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-error {
|
|
||||||
color: var(--danger);
|
|
||||||
background: #fff5f1;
|
|
||||||
border-color: #efcfc3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 20px;
|
|
||||||
margin-bottom: 22px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-header h2 {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--text);
|
|
||||||
font-size: 1.4rem;
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.week-navigation {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.current-week {
|
|
||||||
min-width: 145px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: .84rem;
|
|
||||||
font-weight: 700;
|
|
||||||
text-align: center;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-button {
|
|
||||||
padding: 9px 12px;
|
|
||||||
color: var(--text);
|
|
||||||
font-size: .78rem;
|
|
||||||
font-weight: 700;
|
|
||||||
background: var(--surface-muted);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background var(--transition), border-color var(--transition), transform var(--transition);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-button:hover {
|
|
||||||
background: var(--accent-soft);
|
|
||||||
border-color: #d9bca8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-button:active {
|
|
||||||
transform: translateY(1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-table {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 70px repeat(7, minmax(105px, 1fr));
|
|
||||||
min-width: 810px;
|
|
||||||
overflow: auto;
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
scrollbar-color: #cbbbae transparent;
|
|
||||||
scrollbar-width: thin;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-day-header {
|
|
||||||
display: flex;
|
|
||||||
min-height: 58px;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 3px;
|
|
||||||
padding: 10px 6px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: .76rem;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: capitalize;
|
|
||||||
background: var(--surface-muted);
|
|
||||||
border-right: 1px solid var(--border);
|
|
||||||
border-bottom: 2px solid #dbcfc3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-day-header:first-child {
|
|
||||||
color: #9a8a7d;
|
|
||||||
font-size: .68rem;
|
|
||||||
letter-spacing: .04em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-day-header strong {
|
|
||||||
color: var(--text);
|
|
||||||
font-size: 1.15rem;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-time-axis {
|
|
||||||
height: var(--schedule-height);
|
|
||||||
background: #f8f5f1;
|
|
||||||
border-right: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-time-axis span {
|
|
||||||
display: block;
|
|
||||||
height: 64px;
|
|
||||||
padding: 7px 9px 0 0;
|
|
||||||
color: #998c81;
|
|
||||||
font-size: .68rem;
|
|
||||||
text-align: right;
|
|
||||||
transform: translateY(-8px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-day-column {
|
|
||||||
position: relative;
|
|
||||||
height: var(--schedule-height);
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--surface);
|
|
||||||
border-right: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-day-column:last-child {
|
|
||||||
border-right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-hour-line {
|
|
||||||
display: block;
|
|
||||||
height: 64px;
|
|
||||||
border-bottom: 1px solid #f0ebe6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.attendance-block {
|
|
||||||
position: absolute;
|
|
||||||
right: 5px;
|
|
||||||
left: 5px;
|
|
||||||
min-height: 28px;
|
|
||||||
padding: 7px 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
color: #633c2d;
|
|
||||||
font-size: .72rem;
|
|
||||||
line-height: 1.25;
|
|
||||||
background: var(--accent-soft);
|
|
||||||
border-left: 4px solid var(--accent);
|
|
||||||
border-radius: 7px;
|
|
||||||
box-shadow: 0 3px 8px rgba(75, 45, 26, .12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.attendance-block strong,
|
|
||||||
.attendance-block span {
|
|
||||||
display: block;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.attendance-block strong {
|
|
||||||
color: #633c2d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.attendance-block span {
|
|
||||||
margin-top: 3px;
|
|
||||||
color: #8b5d48;
|
|
||||||
font-size: .65rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 820px) {
|
|
||||||
.weekly-schedule {
|
|
||||||
padding: 18px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-header {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-header h2 {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.week-navigation {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.current-week {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
grid-row: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-button {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,7 +1,6 @@
|
|||||||
import React, { createContext, useState, useEffect, useContext } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import api from '../api/axios';
|
import api from '../api/axios';
|
||||||
|
import { AuthContext } from './authContextValue';
|
||||||
const AuthContext = createContext(null);
|
|
||||||
|
|
||||||
export const AuthProvider = ({ children }) => {
|
export const AuthProvider = ({ children }) => {
|
||||||
const [user, setUser] = useState(null);
|
const [user, setUser] = useState(null);
|
||||||
@ -20,7 +19,6 @@ export const AuthProvider = ({ children }) => {
|
|||||||
const userData = response.data;
|
const userData = response.data;
|
||||||
setUser(userData);
|
setUser(userData);
|
||||||
setIsAdmin(userData.is_admin || false);
|
setIsAdmin(userData.is_admin || false);
|
||||||
setUser(userData);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to verify authentication', error);
|
console.error('Failed to verify authentication', error);
|
||||||
localStorage.removeItem('token');
|
localStorage.removeItem('token');
|
||||||
@ -64,4 +62,3 @@ export const AuthProvider = ({ children }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useAuth = () => useContext(AuthContext);
|
|
||||||
|
|||||||
3
frontend/src/context/authContextValue.js
Normal file
3
frontend/src/context/authContextValue.js
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import { createContext } from 'react';
|
||||||
|
|
||||||
|
export const AuthContext = createContext(null);
|
||||||
4
frontend/src/context/useAuth.js
Normal file
4
frontend/src/context/useAuth.js
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { useContext } from 'react';
|
||||||
|
import { AuthContext } from './authContextValue';
|
||||||
|
|
||||||
|
export const useAuth = () => useContext(AuthContext);
|
||||||
@ -1,75 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import api from '../api/axios';
|
|
||||||
|
|
||||||
const AdminInvitePage = () => {
|
|
||||||
const [inviteLink, setInviteLink] = useState('');
|
|
||||||
const [isCopied, setIsCopied] = useState(false);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
|
|
||||||
const generateInviteLink = async () => {
|
|
||||||
try {
|
|
||||||
setError('');
|
|
||||||
const response = await api.post('/users/invitations/');
|
|
||||||
const newInviteLink = `${window.location.origin}/register?invite=${response.data.token}`;
|
|
||||||
setInviteLink(newInviteLink);
|
|
||||||
setIsCopied(false);
|
|
||||||
} catch (error) {
|
|
||||||
setError('Erreur lors de la génération du lien d\'invitation');
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const copyToClipboard = () => {
|
|
||||||
if (inviteLink) {
|
|
||||||
navigator.clipboard.writeText(inviteLink).then(() => {
|
|
||||||
setIsCopied(true);
|
|
||||||
setTimeout(() => setIsCopied(false), 2000);
|
|
||||||
}).catch((error) => {
|
|
||||||
setError('Impossible de copier le lien.');
|
|
||||||
console.error(error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="card admin-page">
|
|
||||||
<h2>Générer un lien d'invitation</h2>
|
|
||||||
|
|
||||||
{error && <div className="alert alert-danger">{error}</div>}
|
|
||||||
|
|
||||||
<div className="admin-toolbar">
|
|
||||||
<button
|
|
||||||
onClick={generateInviteLink}
|
|
||||||
className="btn btn-primary"
|
|
||||||
>
|
|
||||||
Générer un nouveau lien
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{inviteLink && (
|
|
||||||
<div className="invite-result">
|
|
||||||
<h3>Lien d'invitation généré :</h3>
|
|
||||||
<div className="invite-link-box">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={inviteLink}
|
|
||||||
readOnly
|
|
||||||
className="invite-link-input"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={copyToClipboard}
|
|
||||||
className="btn btn-secondary"
|
|
||||||
>
|
|
||||||
{isCopied ? 'Copié!' : 'Copier'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className="helper-text">
|
|
||||||
Partagez ce lien avec les nouveaux utilisateurs pour qu'ils puissent créer un compte.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AdminInvitePage;
|
|
||||||
@ -1,5 +1,4 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import api from '../api/axios';
|
|
||||||
import { userApi } from '../api/users';
|
import { userApi } from '../api/users';
|
||||||
|
|
||||||
const AdminUsersPage = () => {
|
const AdminUsersPage = () => {
|
||||||
@ -12,8 +11,8 @@ const AdminUsersPage = () => {
|
|||||||
const generateInviteLink = async () => {
|
const generateInviteLink = async () => {
|
||||||
try {
|
try {
|
||||||
setError('');
|
setError('');
|
||||||
const response = await api.post('/users/invitations/');
|
const invitation = await userApi.createInvitation();
|
||||||
const newInviteLink = `${window.location.origin}/register?invite=${response.data.token}`;
|
const newInviteLink = `${window.location.origin}/register?invite=${invitation.token}`;
|
||||||
setInviteLink(newInviteLink);
|
setInviteLink(newInviteLink);
|
||||||
setIsCopied(false);
|
setIsCopied(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -38,8 +37,8 @@ const AdminUsersPage = () => {
|
|||||||
const loadUsers = async () => {
|
const loadUsers = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const response = await api.get('/users/');
|
const users = await userApi.getUsers();
|
||||||
setUsers(response.data);
|
setUsers(users);
|
||||||
} catch (requestError) {
|
} catch (requestError) {
|
||||||
setError('Erreur lors du chargement des utilisateurs');
|
setError('Erreur lors du chargement des utilisateurs');
|
||||||
console.error(requestError);
|
console.error(requestError);
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/useAuth';
|
||||||
import './HomePage.css';
|
import './HomePage.css';
|
||||||
|
|
||||||
const HomePage = () => {
|
const HomePage = () => {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/useAuth';
|
||||||
import './LoginPage.css';
|
import './LoginPage.css';
|
||||||
|
|
||||||
const LoginPage = () => {
|
const LoginPage = () => {
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react';
|
|||||||
import { BrowserQRCodeReader } from '@zxing/browser';
|
import { BrowserQRCodeReader } from '@zxing/browser';
|
||||||
import './TimeClockPage.css';
|
import './TimeClockPage.css';
|
||||||
import { timeApi } from '../api/time';
|
import { timeApi } from '../api/time';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/useAuth';
|
||||||
|
|
||||||
const TimeClockPage = () => {
|
const TimeClockPage = () => {
|
||||||
const videoRef = useRef(null);
|
const videoRef = useRef(null);
|
||||||
@ -11,7 +11,6 @@ const TimeClockPage = () => {
|
|||||||
const qrControlsRef = useRef(null);
|
const qrControlsRef = useRef(null);
|
||||||
const { isAdmin } = useAuth();
|
const { isAdmin } = useAuth();
|
||||||
const [isScanning, setIsScanning] = useState(false);
|
const [isScanning, setIsScanning] = useState(false);
|
||||||
const [scanResult, setScanResult] = useState('');
|
|
||||||
const [scanMessage, setScanMessage] = useState('');
|
const [scanMessage, setScanMessage] = useState('');
|
||||||
const [scanConflict, setScanConflict] = useState(null);
|
const [scanConflict, setScanConflict] = useState(null);
|
||||||
const [conflictTime, setConflictTime] = useState(new Date().toTimeString().slice(0, 5));
|
const [conflictTime, setConflictTime] = useState(new Date().toTimeString().slice(0, 5));
|
||||||
@ -40,17 +39,10 @@ const TimeClockPage = () => {
|
|||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Load time entries when component mounts
|
|
||||||
useEffect(() => {
|
|
||||||
loadTimeEntries();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loadTimeEntries = async () => {
|
const loadTimeEntries = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
|
||||||
const entries = await timeApi.getTimeEntries();
|
const entries = await timeApi.getTimeEntries();
|
||||||
setTimeEntries(entries);
|
setTimeEntries(entries);
|
||||||
setError('');
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Erreur lors du chargement des pointages');
|
setError('Erreur lors du chargement des pointages');
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@ -59,6 +51,11 @@ const TimeClockPage = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Load time entries when component mounts
|
||||||
|
useEffect(() => {
|
||||||
|
loadTimeEntries();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleAddEntry = async (e) => {
|
const handleAddEntry = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!newEntry.start_time || !newEntry.end_time) {
|
if (!newEntry.start_time || !newEntry.end_time) {
|
||||||
@ -172,7 +169,6 @@ const TimeClockPage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleQrResult = async (code) => {
|
const handleQrResult = async (code) => {
|
||||||
setScanResult(code);
|
|
||||||
setScanConflict(null);
|
setScanConflict(null);
|
||||||
stopScanner();
|
stopScanner();
|
||||||
|
|
||||||
@ -257,7 +253,6 @@ const TimeClockPage = () => {
|
|||||||
|
|
||||||
const startScanner = async () => {
|
const startScanner = async () => {
|
||||||
setError('');
|
setError('');
|
||||||
setScanResult('');
|
|
||||||
setScanMessage('');
|
setScanMessage('');
|
||||||
setScanConflict(null);
|
setScanConflict(null);
|
||||||
|
|
||||||
|
|||||||
@ -224,11 +224,10 @@
|
|||||||
|
|
||||||
.multi-user-schedule .attendance-bar-group {
|
.multi-user-schedule .attendance-bar-group {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 50%;
|
left: 5px;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
transform: translateX(-50%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.multi-user-schedule .attendance-user-bar {
|
.multi-user-schedule .attendance-user-bar {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
import { timeApi } from '../api/time';
|
import { timeApi } from '../api/time';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/useAuth';
|
||||||
import './WeeklySummaryPage.css';
|
import './WeeklySummaryPage.css';
|
||||||
|
|
||||||
const DEFAULT_START_HOUR = 8;
|
const DEFAULT_START_HOUR = 8;
|
||||||
@ -45,7 +45,8 @@ const WeeklySummaryPage = ({ userId }) => {
|
|||||||
const bottomPadding = parseFloat(mainStyles.paddingBottom) || 0;
|
const bottomPadding = parseFloat(mainStyles.paddingBottom) || 0;
|
||||||
const footerHeight = footer.getBoundingClientRect().height;
|
const footerHeight = footer.getBoundingClientRect().height;
|
||||||
const tableTop = scheduleTable.getBoundingClientRect().top;
|
const tableTop = scheduleTable.getBoundingClientRect().top;
|
||||||
const remainingHeight = window.innerHeight - tableTop - footerHeight - bottomPadding;
|
const tableHeaderHeight = 58;
|
||||||
|
const remainingHeight = window.innerHeight - tableTop - footerHeight - bottomPadding - tableHeaderHeight;
|
||||||
|
|
||||||
setAvailableScheduleHeight(Math.max(180, remainingHeight));
|
setAvailableScheduleHeight(Math.max(180, remainingHeight));
|
||||||
};
|
};
|
||||||
@ -199,7 +200,7 @@ const WeeklySummaryPage = ({ userId }) => {
|
|||||||
const { startHour, endHour } = getScheduleBounds(weekDays);
|
const { startHour, endHour } = getScheduleBounds(weekDays);
|
||||||
const hourCount = endHour - startHour;
|
const hourCount = endHour - startHour;
|
||||||
const measuredHeight = Math.min(availableScheduleHeight, Math.max(180, viewportHeight - 180));
|
const measuredHeight = Math.min(availableScheduleHeight, Math.max(180, viewportHeight - 180));
|
||||||
const scheduleHeight = Math.min(520, Math.max(180, measuredHeight * 0.78));
|
const scheduleHeight = Math.max(180, measuredHeight);
|
||||||
const hourHeight = Math.min(HOUR_HEIGHT, Math.max(12, scheduleHeight / hourCount));
|
const hourHeight = Math.min(HOUR_HEIGHT, Math.max(12, scheduleHeight / hourCount));
|
||||||
const hours = Array.from({ length: hourCount }, (_, index) => startHour + index);
|
const hours = Array.from({ length: hourCount }, (_, index) => startHour + index);
|
||||||
const formatHour = (hour) => `${String(hour).padStart(2, '0')}:00`;
|
const formatHour = (hour) => `${String(hour).padStart(2, '0')}:00`;
|
||||||
|
|||||||
@ -1,70 +0,0 @@
|
|||||||
// Service pour le stockage des pointages
|
|
||||||
class AttendanceStorage {
|
|
||||||
constructor() {
|
|
||||||
this.attendances = [];
|
|
||||||
this.loadAttendances();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Charger les pointages depuis le localStorage
|
|
||||||
loadAttendances() {
|
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem('attendances');
|
|
||||||
if (stored) {
|
|
||||||
this.attendances = JSON.parse(stored);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erreur lors du chargement des pointages:', error);
|
|
||||||
this.attendances = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sauvegarder les pointages dans le localStorage
|
|
||||||
saveAttendances() {
|
|
||||||
try {
|
|
||||||
localStorage.setItem('attendances', JSON.stringify(this.attendances));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erreur lors de la sauvegarde des pointages:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ajouter un nouveau pointage
|
|
||||||
addAttendance(attendance) {
|
|
||||||
this.attendances.push(attendance);
|
|
||||||
this.saveAttendances();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Obtenir tous les pointages
|
|
||||||
getAllAttendances() {
|
|
||||||
return this.attendances;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Obtenir les pointages pour un utilisateur spécifique
|
|
||||||
getAttendancesByUser(userId) {
|
|
||||||
return this.attendances.filter(attendance => attendance.userId === userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Obtenir les pointages pour une période donnée
|
|
||||||
getAttendancesByPeriod(startDate, endDate) {
|
|
||||||
return this.attendances.filter(attendance => {
|
|
||||||
const attendanceDate = new Date(attendance.date);
|
|
||||||
return attendanceDate >= startDate && attendanceDate <= endDate;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mettre à jour un pointage
|
|
||||||
updateAttendance(id, updatedAttendance) {
|
|
||||||
const index = this.attendances.findIndex(a => a.id === id);
|
|
||||||
if (index !== -1) {
|
|
||||||
this.attendances[index] = { ...this.attendances[index], ...updatedAttendance };
|
|
||||||
this.saveAttendances();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Supprimer un pointage
|
|
||||||
deleteAttendance(id) {
|
|
||||||
this.attendances = this.attendances.filter(attendance => attendance.id !== id);
|
|
||||||
this.saveAttendances();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default new AttendanceStorage();
|
|
||||||
Loading…
x
Reference in New Issue
Block a user