init
This commit is contained in:
commit
5a56f5383d
0
backend/core/__init__.py
Normal file
0
backend/core/__init__.py
Normal file
16
backend/core/asgi.py
Normal file
16
backend/core/asgi.py
Normal file
@ -0,0 +1,16 @@
|
||||
"""
|
||||
ASGI config for core project.
|
||||
|
||||
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
|
||||
|
||||
application = get_asgi_application()
|
||||
155
backend/core/settings.py
Normal file
155
backend/core/settings.py
Normal file
@ -0,0 +1,155 @@
|
||||
"""
|
||||
Django settings for core project.
|
||||
|
||||
Generated by 'django-admin startproject' using Django 5.2.17.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/5.2/topics/settings/
|
||||
|
||||
For the full list of settings and their values, see
|
||||
https://docs.djangoproject.com/en/5.2/ref/settings/
|
||||
"""
|
||||
from pathlib import Path
|
||||
from decouple import config
|
||||
import dj_database_url
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
# Quick-start development settings - unsuitable for production
|
||||
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = config('SECRET_KEY')
|
||||
#it's
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = config('DEBUG', default=False, cast=bool)
|
||||
|
||||
ALLOWED_HOSTS = config('ALLOWED_HOSTS', default='').split(',')
|
||||
if not ALLOWED_HOSTS or ALLOWED_HOSTS == ['']:
|
||||
ALLOWED_HOSTS = ['*']
|
||||
|
||||
# Application definition
|
||||
|
||||
INSTALLED_APPS = [
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
# Third party apps
|
||||
'rest_framework',
|
||||
'rest_framework_simplejwt',
|
||||
'corsheaders',
|
||||
# Local apps
|
||||
'users',
|
||||
'time_tracking',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
'corsheaders.middleware.CorsMiddleware',
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
]
|
||||
|
||||
ROOT_URLCONF = 'core.urls'
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [],
|
||||
'APP_DIRS': True,
|
||||
'OPTIONS': {
|
||||
'context_processors': [
|
||||
'django.template.context_processors.request',
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = 'core.wsgi.application'
|
||||
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
|
||||
|
||||
DATABASES = {
|
||||
'default': dj_database_url.config(
|
||||
default=f"mysql://{config('DB_USER', default='user')}:{config('DB_PASSWORD', default='password')}@{config('DB_HOST', default='host')}:{config('DB_PORT', default='3306')}/{config('DB_NAME', default='name')}"
|
||||
)
|
||||
}
|
||||
|
||||
# Password validation
|
||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/5.2/topics/i18n/
|
||||
|
||||
LANGUAGE_CODE = 'fr-fr'
|
||||
|
||||
TIME_ZONE = 'UTC'
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
USE_TZ = True
|
||||
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/5.2/howto/static-files/
|
||||
|
||||
STATIC_URL = 'static/'
|
||||
|
||||
# CORS configuration
|
||||
CORS_ALLOWED_ORIGINS = [
|
||||
"http://localhost:5173",
|
||||
"http://127.0.0.1:5173",
|
||||
]
|
||||
|
||||
# REST Framework & JWT settings
|
||||
REST_FRAMEWORK = {
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||
),
|
||||
'DEFAULT_PERMISSION_CLASSES': [
|
||||
'rest_framework.permissions.IsAuthenticated',
|
||||
],
|
||||
}
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
SIMPLE_JWT = {
|
||||
'ACCESS_TOKEN_LIFETIME': timedelta(days=1),
|
||||
'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
|
||||
'ROTATE_REFRESH_TOKENS': True,
|
||||
'BLACKLIST_ROTTEN_TOKENS': True,
|
||||
'AUTH_HEADER_TYPES': ('Bearer',),
|
||||
}
|
||||
|
||||
# Default primary key field type
|
||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
|
||||
|
||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||
30
backend/core/urls.py
Normal file
30
backend/core/urls.py
Normal file
@ -0,0 +1,30 @@
|
||||
"""
|
||||
URL configuration for core project.
|
||||
|
||||
The `urlpatterns` list routes URLs to views. For more information please see:
|
||||
https://docs.djangoproject.com/en/5.2/topics/http/urls/
|
||||
Examples:
|
||||
Function views
|
||||
1. Add an import: from my_app import views
|
||||
2. Add a URL to urlpatterns: path('', views.home, name='home')
|
||||
Class-based views
|
||||
1. Add an import: from other_app.views import Home
|
||||
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
|
||||
Including another URLconf
|
||||
1. Import the include() function: from django.urls import include, path
|
||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from django.urls import path, include
|
||||
from rest_framework_simplejwt.views import (
|
||||
TokenObtainPairView,
|
||||
TokenRefreshView,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path('admin/', admin.site.urls),
|
||||
path('api/auth/login/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
|
||||
path('api/auth/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
|
||||
path('api/users/', include('users.urls')),
|
||||
path('api/time/', include('time_tracking.urls')),
|
||||
]
|
||||
16
backend/core/wsgi.py
Normal file
16
backend/core/wsgi.py
Normal file
@ -0,0 +1,16 @@
|
||||
"""
|
||||
WSGI config for core project.
|
||||
|
||||
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
|
||||
|
||||
application = get_wsgi_application()
|
||||
22
backend/manage.py
Normal file
22
backend/manage.py
Normal file
@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env python
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
def main():
|
||||
"""Run administrative tasks."""
|
||||
|
||||
# Exécuter les commandes normales
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Couldn't import Django. Are you sure it's installed and "
|
||||
"available on your PYTHONPATH environment variable? Did you "
|
||||
"forget to activate a virtual environment?"
|
||||
) from exc
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
7
backend/requirements.txt
Normal file
7
backend/requirements.txt
Normal file
@ -0,0 +1,7 @@
|
||||
django
|
||||
djangorestframework
|
||||
djangorestframework-simplejwt
|
||||
django-cors-headers
|
||||
mysqlclient
|
||||
python-decouple
|
||||
dj-database-url
|
||||
0
backend/time_tracking/__init__.py
Normal file
0
backend/time_tracking/__init__.py
Normal file
3
backend/time_tracking/admin.py
Normal file
3
backend/time_tracking/admin.py
Normal file
@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
5
backend/time_tracking/apps.py
Normal file
5
backend/time_tracking/apps.py
Normal file
@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
class TimeTrackingConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'time_tracking'
|
||||
33
backend/time_tracking/migrations/0001_initial.py
Normal file
33
backend/time_tracking/migrations/0001_initial.py
Normal file
@ -0,0 +1,33 @@
|
||||
# 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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
0
backend/time_tracking/migrations/__init__.py
Normal file
0
backend/time_tracking/migrations/__init__.py
Normal file
32
backend/time_tracking/models.py
Normal file
32
backend/time_tracking/models.py
Normal file
@ -0,0 +1,32 @@
|
||||
from django.db import models
|
||||
from django.contrib.auth.models import User
|
||||
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()
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-date', '-start_time']
|
||||
unique_together = ['user', 'date', 'start_time']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.username} - {self.date} ({self.start_time} to {self.end_time})"
|
||||
|
||||
@property
|
||||
def duration(self):
|
||||
"""Calculate duration in hours"""
|
||||
if self.start_time and self.end_time:
|
||||
start = datetime.combine(self.date, self.start_time)
|
||||
end = datetime.combine(self.date, self.end_time)
|
||||
return (end - start).total_seconds() / 3600
|
||||
return 0
|
||||
|
||||
@property
|
||||
def day_of_week(self):
|
||||
"""Get day of week as string"""
|
||||
return self.date.strftime('%A')
|
||||
43
backend/time_tracking/serializers.py
Normal file
43
backend/time_tracking/serializers.py
Normal file
@ -0,0 +1,43 @@
|
||||
from rest_framework import serializers
|
||||
from django.contrib.auth.models import User
|
||||
from .models import TimeEntry
|
||||
|
||||
class TimeEntrySerializer(serializers.ModelSerializer):
|
||||
user = serializers.StringRelatedField(read_only=True)
|
||||
username = serializers.CharField(source='user.username', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = TimeEntry
|
||||
fields = ['id', 'user', 'username', 'date', 'start_time', 'end_time', 'duration', 'created_at']
|
||||
read_only_fields = ['user', 'duration', 'created_at']
|
||||
|
||||
class TimeEntryCreateSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = TimeEntry
|
||||
fields = ['date', 'start_time', 'end_time']
|
||||
|
||||
class TimeEntryUpdateSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = TimeEntry
|
||||
fields = ['start_time', 'end_time']
|
||||
|
||||
class WeeklySummarySerializer(serializers.Serializer):
|
||||
user = serializers.StringRelatedField()
|
||||
username = serializers.CharField()
|
||||
total_hours = serializers.FloatField()
|
||||
days = serializers.ListField()
|
||||
|
||||
def to_representation(self, instance):
|
||||
data = super().to_representation(instance)
|
||||
# Format the days data for frontend consumption
|
||||
formatted_days = []
|
||||
for day in data['days']:
|
||||
formatted_days.append({
|
||||
'date': day['date'],
|
||||
'day': day['day'],
|
||||
'start_time': day['start_time'],
|
||||
'end_time': day['end_time'],
|
||||
'duration': day['duration']
|
||||
})
|
||||
data['days'] = formatted_days
|
||||
return data
|
||||
3
backend/time_tracking/tests.py
Normal file
3
backend/time_tracking/tests.py
Normal file
@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
18
backend/time_tracking/urls.py
Normal file
18
backend/time_tracking/urls.py
Normal file
@ -0,0 +1,18 @@
|
||||
from django.urls import path
|
||||
from .views import (
|
||||
TimeEntryListView,
|
||||
TimeEntryCreateView,
|
||||
TimeEntryDetailView,
|
||||
TimeEntryUpdateView,
|
||||
WeeklySummaryView,
|
||||
UserWeeklySummaryView
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path('entries/', TimeEntryListView.as_view(), name='time-entry-list'),
|
||||
path('entries/create/', TimeEntryCreateView.as_view(), name='time-entry-create'),
|
||||
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'),
|
||||
path('weekly-summary/<int:user_id>/', WeeklySummaryView.as_view(), name='weekly-summary'),
|
||||
]
|
||||
189
backend/time_tracking/views.py
Normal file
189
backend/time_tracking/views.py
Normal file
@ -0,0 +1,189 @@
|
||||
from rest_framework import generics, status
|
||||
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.utils import timezone
|
||||
from datetime import datetime, date, timedelta
|
||||
from .models import TimeEntry
|
||||
from .serializers import (
|
||||
TimeEntrySerializer,
|
||||
TimeEntryCreateSerializer,
|
||||
TimeEntryUpdateSerializer,
|
||||
WeeklySummarySerializer
|
||||
)
|
||||
|
||||
class TimeEntryListView(generics.ListAPIView):
|
||||
serializer_class = TimeEntrySerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
# Staff members can view all entries, regular users only their own
|
||||
if user.is_staff or user.is_superuser:
|
||||
return TimeEntry.objects.all()
|
||||
return TimeEntry.objects.filter(user=user)
|
||||
|
||||
class TimeEntryCreateView(generics.CreateAPIView):
|
||||
serializer_class = TimeEntryCreateSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(user=self.request.user)
|
||||
|
||||
class TimeEntryDetailView(generics.RetrieveUpdateDestroyAPIView):
|
||||
serializer_class = TimeEntrySerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
# Staff members can view all entries, regular users only their own
|
||||
if user.is_staff or user.is_superuser:
|
||||
return TimeEntry.objects.all()
|
||||
return TimeEntry.objects.filter(user=user)
|
||||
|
||||
class TimeEntryUpdateView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def put(self, request, entry_id):
|
||||
try:
|
||||
entry = TimeEntry.objects.get(id=entry_id)
|
||||
# Check if user is authorized to update this entry
|
||||
if not (request.user.is_staff or request.user.is_superuser or entry.user == request.user):
|
||||
return Response(
|
||||
{'error': 'Accès refusé'},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
serializer = TimeEntryUpdateSerializer(entry, data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
except TimeEntry.DoesNotExist:
|
||||
return Response(
|
||||
{'error': 'Pointage non trouvé'},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
class WeeklySummaryView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request, user_id=None):
|
||||
# If user_id is provided and user is staff, get that user's summary
|
||||
# Otherwise, get current user's summary
|
||||
if user_id and (request.user.is_staff or request.user.is_superuser):
|
||||
try:
|
||||
user = User.objects.get(id=user_id)
|
||||
except User.DoesNotExist:
|
||||
return Response(
|
||||
{'error': 'Utilisateur non trouvé'},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
else:
|
||||
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),
|
||||
'end_time': str(entry.end_time),
|
||||
'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 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),
|
||||
'end_time': str(entry.end_time),
|
||||
'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 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)
|
||||
0
backend/users/__init__.py
Normal file
0
backend/users/__init__.py
Normal file
39
backend/users/apps.py
Normal file
39
backend/users/apps.py
Normal file
@ -0,0 +1,39 @@
|
||||
import sys
|
||||
from django.apps import AppConfig
|
||||
|
||||
class UsersConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'users'
|
||||
|
||||
def ready(self):
|
||||
# Only run this logic when Django is ready and not during migrations/tests
|
||||
if 'test' in sys.argv or 'migrate' in sys.argv or 'makemigrations' in sys.argv:
|
||||
return
|
||||
|
||||
# Only run when the server is actually starting
|
||||
if 'runserver' in sys.argv:
|
||||
try:
|
||||
# Import here to avoid issues during Django startup
|
||||
from django.contrib.auth.models import User
|
||||
from decouple import config
|
||||
from django.db import transaction
|
||||
|
||||
# Check if any users exist
|
||||
if not User.objects.exists():
|
||||
# Get credentials from environment variables or use defaults
|
||||
username = config('ADMIN_USERNAME', default='admin')
|
||||
password = config('ADMIN_PASSWORD', default='admin123')
|
||||
email = config('ADMIN_EMAIL', default='admin@boulangerie.fr')
|
||||
|
||||
# Create the admin user
|
||||
with transaction.atomic():
|
||||
user = User.objects.create_superuser(
|
||||
username=username,
|
||||
email=email,
|
||||
password=password
|
||||
)
|
||||
|
||||
print(f'Successfully created admin user "{username}"')
|
||||
except Exception as e:
|
||||
# Don't fail the entire Django startup if there's an error
|
||||
print(f'Error creating admin user: {e}')
|
||||
32
backend/users/management/commands/check_and_create_admin.py
Normal file
32
backend/users/management/commands/check_and_create_admin.py
Normal file
@ -0,0 +1,32 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.contrib.auth.models import User
|
||||
from decouple import config
|
||||
from django.db import transaction
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Check if any users exist and create admin if none exist'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# Check if any users exist
|
||||
if User.objects.exists():
|
||||
self.stdout.write(
|
||||
self.style.WARNING('Users already exist, skipping admin creation')
|
||||
)
|
||||
return
|
||||
|
||||
# Get credentials from environment variables or use defaults
|
||||
username = config('ADMIN_USERNAME', default='admin')
|
||||
password = config('ADMIN_PASSWORD', default='admin123')
|
||||
email = config('ADMIN_EMAIL', default='admin@boulangerie.fr')
|
||||
|
||||
# Create the admin user
|
||||
with transaction.atomic():
|
||||
user = User.objects.create_superuser(
|
||||
username=username,
|
||||
email=email,
|
||||
password=password
|
||||
)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f'Successfully created admin user "{username}"')
|
||||
)
|
||||
38
backend/users/management/commands/create_admin.py
Normal file
38
backend/users/management/commands/create_admin.py
Normal file
@ -0,0 +1,38 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.contrib.auth.models import User
|
||||
from decouple import config
|
||||
import sys
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Create admin user from environment variables'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--force',
|
||||
action='store_true',
|
||||
help='Create admin user even if one already exists',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# Get credentials from environment variables
|
||||
username = config('ADMIN_USERNAME', default='admin')
|
||||
password = config('ADMIN_PASSWORD', default='admin123')
|
||||
email = config('ADMIN_EMAIL', default='admin@boulangerie.fr')
|
||||
|
||||
# Check if admin user already exists
|
||||
if User.objects.filter(username=username).exists() and not options['force']:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(f'Admin user "{username}" already exists')
|
||||
)
|
||||
return
|
||||
|
||||
# Create the admin user
|
||||
user = User.objects.create_superuser(
|
||||
username=username,
|
||||
email=email,
|
||||
password=password
|
||||
)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f'Successfully created admin user "{username}"')
|
||||
)
|
||||
26
backend/users/migrations/0001_invitation.py
Normal file
26
backend/users/migrations/0001_invitation.py
Normal file
@ -0,0 +1,26 @@
|
||||
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)),
|
||||
],
|
||||
),
|
||||
]
|
||||
0
backend/users/migrations/__init__.py
Normal file
0
backend/users/migrations/__init__.py
Normal file
17
backend/users/models.py
Normal file
17
backend/users/models.py
Normal file
@ -0,0 +1,17 @@
|
||||
from django.db import models
|
||||
from django.contrib.auth.models import User
|
||||
import uuid
|
||||
|
||||
# Create your models here.
|
||||
|
||||
class Invitation(models.Model):
|
||||
token = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
|
||||
created_by = models.ForeignKey(User, on_delete=models.CASCADE, related_name='created_invitations')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
expires_at = models.DateTimeField()
|
||||
used_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
@property
|
||||
def is_valid(self):
|
||||
from django.utils import timezone
|
||||
return self.used_at is None and self.expires_at > timezone.now()
|
||||
46
backend/users/serializers.py
Normal file
46
backend/users/serializers.py
Normal file
@ -0,0 +1,46 @@
|
||||
from django.contrib.auth.models import User
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from rest_framework import serializers
|
||||
|
||||
from .models import Invitation
|
||||
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
is_admin = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ['id', 'username', 'email', 'first_name', 'last_name', 'is_admin', 'date_joined']
|
||||
|
||||
def get_is_admin(self, obj):
|
||||
return obj.is_superuser or obj.is_staff
|
||||
|
||||
|
||||
class RegisterSerializer(serializers.ModelSerializer):
|
||||
password = serializers.CharField(write_only=True)
|
||||
invite = serializers.UUIDField(write_only=True)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ['username', 'password', 'email', 'first_name', 'last_name', 'invite']
|
||||
|
||||
def validate_invite(self, value):
|
||||
try:
|
||||
invitation = Invitation.objects.get(token=value)
|
||||
except Invitation.DoesNotExist as error:
|
||||
raise serializers.ValidationError('Ce lien d’invitation est invalide.') from error
|
||||
if not invitation.is_valid:
|
||||
raise serializers.ValidationError('Ce lien d’invitation est expire ou deja utilise.')
|
||||
return value
|
||||
|
||||
def create(self, validated_data):
|
||||
invitation_token = validated_data.pop('invite')
|
||||
with transaction.atomic():
|
||||
invitation = Invitation.objects.select_for_update().get(token=invitation_token)
|
||||
if not invitation.is_valid:
|
||||
raise serializers.ValidationError({'invite': 'Ce lien d’invitation est expire ou deja utilise.'})
|
||||
user = User.objects.create_user(**validated_data)
|
||||
invitation.used_at = timezone.now()
|
||||
invitation.save(update_fields=['used_at'])
|
||||
return user
|
||||
3
backend/users/tests.py
Normal file
3
backend/users/tests.py
Normal file
@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
12
backend/users/urls.py
Normal file
12
backend/users/urls.py
Normal file
@ -0,0 +1,12 @@
|
||||
from django.urls import path
|
||||
from .views import InvitationCreateView, RegisterView, UserListView, UserMeView, UserDetailView, UserPromoteView, UserDeleteView
|
||||
|
||||
urlpatterns = [
|
||||
path('register/', RegisterView.as_view(), name='register'),
|
||||
path('me/', UserMeView.as_view(), name='me'),
|
||||
path('', UserListView.as_view(), name='user-list'),
|
||||
path('<int:user_id>/', UserDetailView.as_view(), name='user-detail'),
|
||||
path('<int:user_id>/promote/', UserPromoteView.as_view(), name='user-promote'),
|
||||
path('<int:user_id>/delete/', UserDeleteView.as_view(), name='user-delete'),
|
||||
path('invitations/', InvitationCreateView.as_view(), name='invitation-create'),
|
||||
]
|
||||
109
backend/users/views.py
Normal file
109
backend/users/views.py
Normal file
@ -0,0 +1,109 @@
|
||||
from rest_framework import generics
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
from django.contrib.auth.models import User
|
||||
from .models import Invitation
|
||||
from .serializers import UserSerializer, RegisterSerializer
|
||||
|
||||
class RegisterView(generics.CreateAPIView):
|
||||
queryset = User.objects.all()
|
||||
permission_classes = (AllowAny,)
|
||||
serializer_class = RegisterSerializer
|
||||
|
||||
class UserMeView(generics.RetrieveAPIView):
|
||||
permission_classes = (IsAuthenticated,)
|
||||
serializer_class = UserSerializer
|
||||
|
||||
def get_object(self):
|
||||
return self.request.user
|
||||
|
||||
|
||||
class UserListView(generics.ListAPIView):
|
||||
permission_classes = (IsAuthenticated,)
|
||||
serializer_class = UserSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
if not (self.request.user.is_staff or self.request.user.is_superuser):
|
||||
raise PermissionDenied('Seuls les administrateurs peuvent consulter les comptes.')
|
||||
return User.objects.order_by('-date_joined')
|
||||
|
||||
|
||||
class UserDetailView(generics.RetrieveUpdateDestroyAPIView):
|
||||
permission_classes = (IsAuthenticated,)
|
||||
serializer_class = UserSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
if not (self.request.user.is_staff or self.request.user.is_superuser):
|
||||
raise PermissionDenied('Seuls les administrateurs peuvent modifier les comptes.')
|
||||
return User.objects.all()
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
# For updating user permissions, we need to handle is_staff and is_superuser specially
|
||||
if not (request.user.is_staff or request.user.is_superuser):
|
||||
raise PermissionDenied('Seuls les administrateurs peuvent modifier les comptes.')
|
||||
|
||||
user = self.get_object()
|
||||
# Only allow updating specific fields
|
||||
allowed_fields = ['is_staff', 'is_superuser']
|
||||
for field in allowed_fields:
|
||||
if field in request.data:
|
||||
setattr(user, field, request.data[field])
|
||||
|
||||
user.save()
|
||||
serializer = self.get_serializer(user)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class UserPromoteView(APIView):
|
||||
permission_classes = (IsAuthenticated,)
|
||||
|
||||
def post(self, request, user_id):
|
||||
if not (request.user.is_staff or request.user.is_superuser):
|
||||
raise PermissionDenied('Seuls les administrateurs peuvent promouvoir des utilisateurs.')
|
||||
|
||||
try:
|
||||
user = User.objects.get(id=user_id)
|
||||
user.is_staff = True
|
||||
user.save()
|
||||
return Response({'message': 'Utilisateur promu avec succès'}, status=200)
|
||||
except User.DoesNotExist:
|
||||
return Response({'error': 'Utilisateur non trouvé'}, status=404)
|
||||
|
||||
|
||||
class UserDeleteView(APIView):
|
||||
permission_classes = (IsAuthenticated,)
|
||||
|
||||
def delete(self, request, user_id):
|
||||
if not (request.user.is_staff or request.user.is_superuser):
|
||||
raise PermissionDenied('Seuls les administrateurs peuvent supprimer des utilisateurs.')
|
||||
|
||||
try:
|
||||
user = User.objects.get(id=user_id)
|
||||
# Ne pas permettre de supprimer l'utilisateur courant
|
||||
if user == request.user:
|
||||
return Response({'error': 'Vous ne pouvez pas supprimer votre propre compte'}, status=400)
|
||||
|
||||
user.delete()
|
||||
return Response({'message': 'Utilisateur supprimé avec succès'}, status=200)
|
||||
except User.DoesNotExist:
|
||||
return Response({'error': 'Utilisateur non trouvé'}, status=404)
|
||||
|
||||
|
||||
class InvitationCreateView(APIView):
|
||||
permission_classes = (IsAuthenticated,)
|
||||
|
||||
def post(self, request):
|
||||
if not (request.user.is_staff or request.user.is_superuser):
|
||||
raise PermissionDenied('Seuls les administrateurs peuvent créer une invitation.')
|
||||
invitation = Invitation.objects.create(
|
||||
created_by=request.user,
|
||||
expires_at=timezone.now() + timedelta(days=7),
|
||||
)
|
||||
return Response({
|
||||
'token': str(invitation.token),
|
||||
'expires_at': invitation.expires_at,
|
||||
}, status=201)
|
||||
2
frontend/.dockerignore
Normal file
2
frontend/.dockerignore
Normal file
@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
dist
|
||||
8
frontend/.oxlintrc.json
Normal file
8
frontend/.oxlintrc.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "oxc"],
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
17
frontend/index.html
Normal file
17
frontend/index.html
Normal file
@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="text" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🍞</text></svg>" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Le Fournil du Bocage</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
1652
frontend/package-lock.json
generated
Normal file
1652
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
frontend/package.json
Normal file
25
frontend/package.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "app",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "oxlint",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.20.0",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-router-dom": "^7.18.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@vitejs/plugin-react": "^6.1.0",
|
||||
"oxlint": "^1.79.0",
|
||||
"vite": "^8.2.2"
|
||||
}
|
||||
}
|
||||
307
frontend/src/App.css
Normal file
307
frontend/src/App.css
Normal file
@ -0,0 +1,307 @@
|
||||
#root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
main {
|
||||
width: min(1120px, 100%);
|
||||
flex: 1;
|
||||
margin: 0 auto;
|
||||
padding: 2.5rem 1.5rem 4rem;
|
||||
}
|
||||
|
||||
footer {
|
||||
padding: 1.5rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
background: var(--surface);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
footer p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
min-height: 44px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.7rem 1.1rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.btn:focus-visible,
|
||||
input:focus-visible {
|
||||
outline: 3px solid rgba(168, 93, 42, 0.22);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
color: #fff;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 5px 12px rgba(168, 93, 42, 0.2);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-dark);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
color: var(--text);
|
||||
background: var(--surface);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
color: var(--accent-dark);
|
||||
background: var(--surface-muted);
|
||||
border-color: #d7c5b7;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
min-height: 34px;
|
||||
padding: 0.45rem 0.7rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
color: var(--danger);
|
||||
background: #fff;
|
||||
border-color: #efc7c3;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
color: #fff;
|
||||
background: var(--danger);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.admin-page h2 {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.admin-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.7rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.invite-result {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.invite-result h3,
|
||||
.info-panel h3 {
|
||||
margin: 0 0 0.75rem;
|
||||
color: var(--text);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.invite-link-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
padding: 0.7rem;
|
||||
background: var(--surface-muted);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.invite-link-input {
|
||||
min-width: 0;
|
||||
padding: 0.5rem;
|
||||
color: var(--text-muted);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.helper-text {
|
||||
margin: 0.7rem 0 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.info-panel {
|
||||
margin-top: 2rem;
|
||||
padding: 1.1rem 1.25rem;
|
||||
background: var(--surface-muted);
|
||||
border: 1px dashed #d7c5b7;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.info-panel ul {
|
||||
margin: 0;
|
||||
padding-left: 1.25rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.info-panel li + li {
|
||||
margin-top: 0.45rem;
|
||||
}
|
||||
|
||||
form,
|
||||
.card,
|
||||
.profile-card {
|
||||
padding: clamp(1.35rem, 4vw, 2.25rem);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
form {
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.card {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.card h2,
|
||||
.profile-card h2 {
|
||||
margin: 0 0 1.5rem;
|
||||
color: var(--text);
|
||||
font-size: clamp(1.35rem, 3vw, 1.8rem);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.2rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.45rem;
|
||||
color: var(--text);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
input[type="text"],
|
||||
input[type="password"] {
|
||||
width: 100%;
|
||||
min-height: 46px;
|
||||
padding: 0.7rem 0.8rem;
|
||||
color: var(--text);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
input[type="text"]:focus,
|
||||
input[type="password"]:focus {
|
||||
border-color: var(--accent);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(168, 93, 42, 0.12);
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
overflow: hidden;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 0.9rem 1rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
th {
|
||||
color: var(--text);
|
||||
background: var(--surface-muted);
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: #fcfaf8;
|
||||
}
|
||||
|
||||
.alert {
|
||||
margin-bottom: 1.1rem;
|
||||
padding: 0.8rem 1rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.alert-danger,
|
||||
.alert-error {
|
||||
color: var(--danger);
|
||||
background: #fcebea;
|
||||
border: 1px solid #f2c9c5;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
color: var(--success);
|
||||
background: #eaf5ee;
|
||||
border: 1px solid #c7e5d2;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: grid;
|
||||
min-height: 240px;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border: 3px solid var(--accent-soft);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
main {
|
||||
padding: 1.25rem 1rem 2.5rem;
|
||||
}
|
||||
|
||||
.table-responsive {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
min-width: 680px;
|
||||
}
|
||||
}
|
||||
54
frontend/src/App.jsx
Normal file
54
frontend/src/App.jsx
Normal file
@ -0,0 +1,54 @@
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import HomePage from './pages/HomePage';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
import Header from './components/Header';
|
||||
import AdminUsersPage from './pages/AdminUsersPage';
|
||||
import AdminInvitePage from './pages/AdminInvitePage';
|
||||
import RegisterPage from './pages/RegisterPage';
|
||||
import TimeClockPage from './pages/TimeClockPage';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div id="root">
|
||||
<Header />
|
||||
<main>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
<Route path="/pointage" element={
|
||||
<ProtectedRoute>
|
||||
<TimeClockPage />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<HomePage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/users" element={
|
||||
<ProtectedRoute>
|
||||
<AdminUsersPage />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/invite" element={
|
||||
<ProtectedRoute>
|
||||
<AdminInvitePage />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</main>
|
||||
<footer className="site-footer">
|
||||
<p>© 2026 Le Fournil du Bocage. Tous droits réservés.</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
45
frontend/src/api/axios.js
Normal file
45
frontend/src/api/axios.js
Normal file
@ -0,0 +1,45 @@
|
||||
import axios from 'axios';
|
||||
|
||||
// Utiliser l'URL correcte pour le backend
|
||||
const api = axios.create({
|
||||
baseURL: 'http://localhost:8000/api',
|
||||
});
|
||||
|
||||
// Ajouter un intercepteur pour gérer les erreurs de réseau
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.code === 'ECONNREFUSED') {
|
||||
console.error('Connexion refusée : le serveur backend n\'est pas accessible');
|
||||
} else if (error.response && error.response.status === 401) {
|
||||
// Token expired or invalid
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
// Rediriger vers la page de login
|
||||
if (window.location.pathname !== '/login') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Ajouter un intercepteur pour les requêtes
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
// Ajouter un header pour les requêtes CORS
|
||||
config.headers['Content-Type'] = 'application/json';
|
||||
config.headers['Accept'] = 'application/json';
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default api;
|
||||
|
||||
73
frontend/src/api/time.js
Normal file
73
frontend/src/api/time.js
Normal file
@ -0,0 +1,73 @@
|
||||
import api from './axios';
|
||||
|
||||
export const timeApi = {
|
||||
// Get all time entries
|
||||
getTimeEntries: async () => {
|
||||
try {
|
||||
const response = await api.get('/time/entries/');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error('Failed to fetch time entries: ' + error.message);
|
||||
}
|
||||
},
|
||||
|
||||
// Create a new time entry
|
||||
createTimeEntry: async (entryData) => {
|
||||
try {
|
||||
const response = await api.post('/time/entries/create/', entryData);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error('Failed to create time entry: ' + error.message);
|
||||
}
|
||||
},
|
||||
|
||||
// Get a specific time entry
|
||||
getTimeEntry: async (id) => {
|
||||
try {
|
||||
const response = await api.get(`/time/entries/${id}/`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error('Failed to fetch time entry: ' + error.message);
|
||||
}
|
||||
},
|
||||
|
||||
// Update a time entry
|
||||
updateTimeEntry: async (id, entryData) => {
|
||||
try {
|
||||
const response = await api.put(`/time/entries/${id}/update/`, entryData);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error('Failed to update time entry: ' + error.message);
|
||||
}
|
||||
},
|
||||
|
||||
// Delete a time entry
|
||||
deleteTimeEntry: async (id) => {
|
||||
try {
|
||||
const response = await api.delete(`/time/entries/${id}/`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error('Failed to delete time entry: ' + error.message);
|
||||
}
|
||||
},
|
||||
|
||||
// Get weekly summary for current user
|
||||
getWeeklySummary: async () => {
|
||||
try {
|
||||
const response = await api.get('/time/weekly-summary/');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error('Failed to fetch weekly summary: ' + error.message);
|
||||
}
|
||||
},
|
||||
|
||||
// Get weekly summary for a specific user (admin only)
|
||||
getUserWeeklySummary: async (userId) => {
|
||||
try {
|
||||
const response = await api.get(`/time/weekly-summary/${userId}/`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error('Failed to fetch user weekly summary: ' + error.message);
|
||||
}
|
||||
},
|
||||
};
|
||||
74
frontend/src/api/users.js
Normal file
74
frontend/src/api/users.js
Normal file
@ -0,0 +1,74 @@
|
||||
import api from './axios';
|
||||
|
||||
export const userApi = {
|
||||
// Get all users
|
||||
getUsers: async () => {
|
||||
try {
|
||||
const response = await api.get('/users/');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error('Failed to fetch users: ' + error.message);
|
||||
}
|
||||
},
|
||||
|
||||
// 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
|
||||
deleteUser: async (id) => {
|
||||
try {
|
||||
// Assurer que l'URL se termine par un slash
|
||||
const response = await api.delete(`/users/${id}/`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error('Failed to delete user: ' + error.message);
|
||||
}
|
||||
},
|
||||
|
||||
// Promote a user to admin
|
||||
promoteUser: async (id) => {
|
||||
try {
|
||||
const response = await api.post(`/users/${id}/promote/`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error('Failed to promote user: ' + error.message);
|
||||
}
|
||||
},
|
||||
|
||||
// Get user roles
|
||||
getUserRoles: async () => {
|
||||
try {
|
||||
const response = await api.get('/api/users/roles/');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error('Failed to fetch user roles: ' + error.message);
|
||||
}
|
||||
},
|
||||
};
|
||||
69
frontend/src/components/AttendanceApp.css
Normal file
69
frontend/src/components/AttendanceApp.css
Normal file
@ -0,0 +1,69 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
39
frontend/src/components/AttendanceApp.jsx
Normal file
39
frontend/src/components/AttendanceApp.jsx
Normal file
@ -0,0 +1,39 @@
|
||||
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;
|
||||
220
frontend/src/components/Header.css
Normal file
220
frontend/src/components/Header.css
Normal file
@ -0,0 +1,220 @@
|
||||
/* Site header */
|
||||
.site-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
padding: 0 2rem;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border-bottom: 1px solid var(--border);
|
||||
box-shadow: 0 4px 18px rgba(15, 23, 42, 0.06);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.header-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
max-width: 1120px;
|
||||
min-height: 76px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
color: var(--text);
|
||||
font-size: 1.15rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: -0.2px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.brand:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
display: grid;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
place-items: center;
|
||||
background: var(--accent-soft);
|
||||
border-radius: 12px;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.hamburger-btn {
|
||||
display: flex;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.hamburger-line {
|
||||
width: 19px;
|
||||
height: 2px;
|
||||
background-color: var(--text);
|
||||
border-radius: 99px;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.hamburger-btn:hover,
|
||||
.hamburger-btn:focus-visible {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.hamburger-btn:hover .hamburger-line,
|
||||
.hamburger-btn.open .hamburger-line {
|
||||
background-color: var(--accent);
|
||||
}
|
||||
|
||||
.hamburger-btn.open .hamburger-line:nth-child(1) {
|
||||
transform: translateY(7px) rotate(45deg);
|
||||
}
|
||||
|
||||
.hamburger-btn.open .hamburger-line:nth-child(2) {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.hamburger-btn.open .hamburger-line:nth-child(3) {
|
||||
transform: translateY(-7px) rotate(-45deg);
|
||||
}
|
||||
|
||||
.hamburger-menu-overlay {
|
||||
position: fixed;
|
||||
inset: 76px 0 0;
|
||||
z-index: 99;
|
||||
background: rgba(15, 23, 42, 0.32);
|
||||
animation: fade-in 0.2s ease;
|
||||
}
|
||||
|
||||
.hamburger-menu-content {
|
||||
width: min(360px, 100%);
|
||||
min-height: calc(100% - 1rem);
|
||||
margin-left: auto;
|
||||
padding: 1.25rem;
|
||||
background: var(--surface);
|
||||
border-left: 1px solid var(--border);
|
||||
box-shadow: -12px 0 30px rgba(15, 23, 42, 0.12);
|
||||
animation: slide-in 0.22s ease;
|
||||
}
|
||||
|
||||
.menu-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 1.25rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.menu-heading strong {
|
||||
display: block;
|
||||
margin-top: 0.2rem;
|
||||
color: var(--text);
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.menu-eyebrow {
|
||||
display: block;
|
||||
color: var(--accent);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
color: var(--text);
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.close-btn:hover,
|
||||
.close-btn:focus-visible {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.menu-links {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.menu-link {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
min-height: 48px;
|
||||
padding: 0.75rem 0.9rem;
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.menu-link:hover,
|
||||
.menu-link:focus-visible {
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.menu-logout {
|
||||
margin-top: 0.7rem;
|
||||
border-top: 1px solid var(--border);
|
||||
border-radius: 0;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slide-in {
|
||||
from { transform: translateX(100%); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.site-header {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.header-container {
|
||||
min-height: 68px;
|
||||
}
|
||||
|
||||
.hamburger-menu-overlay {
|
||||
inset: 68px 0 0;
|
||||
}
|
||||
|
||||
.hamburger-menu-content {
|
||||
min-height: 100%;
|
||||
}
|
||||
}
|
||||
92
frontend/src/components/Header.jsx
Normal file
92
frontend/src/components/Header.jsx
Normal file
@ -0,0 +1,92 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import './Header.css';
|
||||
|
||||
const Header = () => {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const toggleMenu = () => {
|
||||
setIsMenuOpen((isOpen) => !isOpen);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleEscape = (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
setIsMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => document.removeEventListener('keydown', handleEscape);
|
||||
}, []);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/login');
|
||||
setIsMenuOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="site-header">
|
||||
<div className="header-container">
|
||||
<Link to="/" className="brand" aria-label="Retour à l'accueil">
|
||||
<span className="brand-mark" aria-hidden="true">🍞</span>
|
||||
<span>Le Fournil du Bocage</span>
|
||||
</Link>
|
||||
<button
|
||||
className={`hamburger-btn${isMenuOpen ? ' open' : ''}`}
|
||||
onClick={toggleMenu}
|
||||
aria-expanded={isMenuOpen}
|
||||
aria-controls="main-menu"
|
||||
aria-label={isMenuOpen ? 'Fermer le menu' : 'Ouvrir le menu'}
|
||||
>
|
||||
<span className="hamburger-line" />
|
||||
<span className="hamburger-line" />
|
||||
<span className="hamburger-line" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Menu hamburger */}
|
||||
{isMenuOpen && (
|
||||
<div className="hamburger-menu-overlay" onClick={toggleMenu}>
|
||||
<div
|
||||
className="hamburger-menu-content"
|
||||
id="main-menu"
|
||||
role="dialog"
|
||||
aria-label="Menu principal"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="menu-heading">
|
||||
<div>
|
||||
<span className="menu-eyebrow">Navigation</span>
|
||||
<strong>{user ? `Bonjour ${user.username}` : 'Bienvenue'}</strong>
|
||||
</div>
|
||||
<button className="close-btn" onClick={toggleMenu} aria-label="Fermer le menu">×</button>
|
||||
</div>
|
||||
<div className="menu-links">
|
||||
{user ? (
|
||||
<>
|
||||
<Link to="/" className="menu-link" onClick={toggleMenu}>Profil</Link>
|
||||
<Link to="/pointage" className="menu-link" onClick={toggleMenu}>Pointage</Link>
|
||||
{user.is_admin && (
|
||||
<>
|
||||
<Link to="/users" className="menu-link" onClick={toggleMenu}>Comptes existants</Link>
|
||||
</>
|
||||
)}
|
||||
<button onClick={handleLogout} className="menu-link menu-logout">Déconnexion</button>
|
||||
</>
|
||||
) : (
|
||||
<Link to="/login" className="menu-link" onClick={toggleMenu}>Connexion</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
21
frontend/src/components/ProtectedRoute.jsx
Normal file
21
frontend/src/components/ProtectedRoute.jsx
Normal file
@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
const ProtectedRoute = ({ children }) => {
|
||||
const { user, loading } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (loading) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
// Redirect them to the /login page, but save the current location they were trying to go to.
|
||||
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
export default ProtectedRoute;
|
||||
68
frontend/src/components/RecapTab.css
Normal file
68
frontend/src/components/RecapTab.css
Normal file
@ -0,0 +1,68 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
56
frontend/src/components/RecapTab.jsx
Normal file
56
frontend/src/components/RecapTab.jsx
Normal file
@ -0,0 +1,56 @@
|
||||
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;
|
||||
303
frontend/src/components/TimeClockQR.css
Normal file
303
frontend/src/components/TimeClockQR.css
Normal file
@ -0,0 +1,303 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
203
frontend/src/components/TimeClockQR.jsx
Normal file
203
frontend/src/components/TimeClockQR.jsx
Normal file
@ -0,0 +1,203 @@
|
||||
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;
|
||||
121
frontend/src/components/WeeklySchedule.css
Normal file
121
frontend/src/components/WeeklySchedule.css
Normal file
@ -0,0 +1,121 @@
|
||||
.weekly-schedule {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.weekly-schedule.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.schedule-header {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.schedule-header h2 {
|
||||
color: #333;
|
||||
margin-bottom: 15px;
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.week-navigation {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.current-week {
|
||||
font-weight: bold;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.nav-button:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
|
||||
.schedule-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.schedule-header-row {
|
||||
display: flex;
|
||||
border-bottom: 2px solid #eee;
|
||||
font-weight: bold;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.schedule-day-header,
|
||||
.schedule-time-header {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.schedule-row {
|
||||
display: flex;
|
||||
border-bottom: 1px solid #eee;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.schedule-row:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.schedule-day,
|
||||
.schedule-time {
|
||||
padding: 12px;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.schedule-day {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.schedule-time {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.schedule-header-row,
|
||||
.schedule-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.schedule-day-header,
|
||||
.schedule-time-header,
|
||||
.schedule-day,
|
||||
.schedule-time {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.week-navigation {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
102
frontend/src/components/WeeklySchedule.jsx
Normal file
102
frontend/src/components/WeeklySchedule.jsx
Normal file
@ -0,0 +1,102 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import attendanceStorage from '../services/attendanceStorage';
|
||||
import './WeeklySchedule.css';
|
||||
|
||||
const WeeklySchedule = ({ userId }) => {
|
||||
const [attendances, setAttendances] = useState([]);
|
||||
const [currentWeek, setCurrentWeek] = useState(new Date());
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Charger les pointages depuis le service
|
||||
useEffect(() => {
|
||||
const loadAttendances = () => {
|
||||
const userAttendances = attendanceStorage.getAttendancesByUser(userId);
|
||||
setAttendances(userAttendances);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
loadAttendances();
|
||||
}, [userId]);
|
||||
|
||||
// Fonction pour obtenir les jours de la semaine
|
||||
const getWeekDays = (date) => {
|
||||
const days = [];
|
||||
const startOfWeek = new Date(date);
|
||||
const day = startOfWeek.getDay();
|
||||
const diff = startOfWeek.getDate() - day;
|
||||
startOfWeek.setDate(diff);
|
||||
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const day = new Date(startOfWeek);
|
||||
day.setDate(startOfWeek.getDate() + i);
|
||||
days.push(day);
|
||||
}
|
||||
return days;
|
||||
};
|
||||
|
||||
// Fonction pour formater la date
|
||||
const formatDate = (date) => {
|
||||
return date.toLocaleDateString('fr-FR', {
|
||||
weekday: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
// Fonction pour obtenir les heures de présence pour un jour
|
||||
const getHoursForDay = (date) => {
|
||||
const attendance = attendances.find(a =>
|
||||
new Date(a.date).toDateString() === date.toDateString()
|
||||
);
|
||||
|
||||
if (attendance) {
|
||||
return `${attendance.startTime} - ${attendance.endTime}`;
|
||||
}
|
||||
return 'Absent';
|
||||
};
|
||||
|
||||
// Fonction pour naviguer entre les semaines
|
||||
const navigateWeek = (direction) => {
|
||||
const newWeek = new Date(currentWeek);
|
||||
newWeek.setDate(currentWeek.getDate() + (direction * 7));
|
||||
setCurrentWeek(newWeek);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="weekly-schedule loading">Chargement...</div>;
|
||||
}
|
||||
|
||||
const weekDays = getWeekDays(currentWeek);
|
||||
const weekDates = weekDays.map(day => formatDate(day));
|
||||
|
||||
return (
|
||||
<div className="weekly-schedule">
|
||||
<div className="schedule-header">
|
||||
<h2>Emploi du temps hebdomadaire</h2>
|
||||
<div className="week-navigation">
|
||||
<button onClick={() => navigateWeek(-1)} className="nav-button">Semaine précédente</button>
|
||||
<span className="current-week">
|
||||
Semaine du {weekDays[0].toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' })}
|
||||
au {weekDays[6].toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' })}
|
||||
</span>
|
||||
<button onClick={() => navigateWeek(1)} className="nav-button">Semaine suivante</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="schedule-table">
|
||||
<div className="schedule-header-row">
|
||||
<div className="schedule-day-header">Jour</div>
|
||||
<div className="schedule-time-header">Heures de présence</div>
|
||||
</div>
|
||||
|
||||
{weekDays.map((day, index) => (
|
||||
<div key={index} className="schedule-row">
|
||||
<div className="schedule-day">{weekDates[index]}</div>
|
||||
<div className="schedule-time">{getHoursForDay(day)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WeeklySchedule;
|
||||
67
frontend/src/context/AuthContext.jsx
Normal file
67
frontend/src/context/AuthContext.jsx
Normal file
@ -0,0 +1,67 @@
|
||||
import React, { createContext, useState, useEffect, useContext } from 'react';
|
||||
import api from '../api/axios';
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
export const AuthProvider = ({ children }) => {
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
const token = localStorage.getItem('token');
|
||||
const savedUser = localStorage.getItem('user');
|
||||
|
||||
if (token && savedUser) {
|
||||
try {
|
||||
// Verify token by fetching user profile
|
||||
const response = await api.get('/users/me/');
|
||||
const userData = response.data;
|
||||
setUser(userData);
|
||||
setIsAdmin(userData.is_admin || false);
|
||||
setUser(userData);
|
||||
} catch (error) {
|
||||
console.error('Failed to verify authentication', error);
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
const login = async (username, password) => {
|
||||
const response = await api.post('/auth/login/', { username, password });
|
||||
const { access, refresh } = response.data;
|
||||
localStorage.setItem('token', access);
|
||||
localStorage.setItem('refresh', refresh);
|
||||
|
||||
// Fetch user info after login
|
||||
const userResponse = await api.get('/users/me/');
|
||||
const userData = userResponse.data;
|
||||
localStorage.setItem('user', JSON.stringify(userData));
|
||||
setUser(userData);
|
||||
setIsAdmin(userData.is_admin || false);
|
||||
return userData;
|
||||
};
|
||||
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('refresh');
|
||||
localStorage.removeItem('user');
|
||||
setUser(null);
|
||||
setIsAdmin(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, login, logout, loading, isAdmin }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAuth = () => useContext(AuthContext);
|
||||
47
frontend/src/main.css
Normal file
47
frontend/src/main.css
Normal file
@ -0,0 +1,47 @@
|
||||
:root {
|
||||
--bg: #f8f5f0;
|
||||
--surface: #ffffff;
|
||||
--surface-muted: #f3eee8;
|
||||
--text: #342820;
|
||||
--text-muted: #75685f;
|
||||
--border: #e7ddd4;
|
||||
--accent: #a85d2a;
|
||||
--accent-dark: #84441d;
|
||||
--accent-soft: #f5e5d8;
|
||||
--danger: #b64235;
|
||||
--success: #377a58;
|
||||
--shadow: 0 10px 30px rgba(75, 45, 26, 0.08);
|
||||
--shadow-lg: 0 20px 50px rgba(75, 45, 26, 0.14);
|
||||
--radius: 16px;
|
||||
--radius-sm: 10px;
|
||||
--transition: 180ms ease;
|
||||
--sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
|
||||
font-family: var(--sans);
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
min-width: 320px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
body {
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
font: inherit;
|
||||
}
|
||||
16
frontend/src/main.jsx
Normal file
16
frontend/src/main.jsx
Normal file
@ -0,0 +1,16 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { AuthProvider } from './context/AuthContext'
|
||||
import './main.css'
|
||||
import App from './App.jsx'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
85
frontend/src/pages/AdminInvitePage.jsx
Normal file
85
frontend/src/pages/AdminInvitePage.jsx
Normal file
@ -0,0 +1,85 @@
|
||||
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 className="info-panel">
|
||||
<h3>Comment cela fonctionne-t-il ?</h3>
|
||||
<ul>
|
||||
<li>Les utilisateurs doivent utiliser un lien d'invitation pour créer un compte</li>
|
||||
<li>Cela permet de contrôler qui peut accéder à l'application</li>
|
||||
<li>Les nouveaux comptes sont créés avec des permissions standard</li>
|
||||
<li>Vous pouvez promouvoir des utilisateurs en administrateurs depuis la page de gestion</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminInvitePage;
|
||||
129
frontend/src/pages/AdminUsersPage.jsx
Normal file
129
frontend/src/pages/AdminUsersPage.jsx
Normal file
@ -0,0 +1,129 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import api from '../api/axios';
|
||||
import { userApi } from '../api/users';
|
||||
|
||||
const AdminUsersPage = () => {
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await api.get('/users/');
|
||||
setUsers(response.data);
|
||||
} catch (requestError) {
|
||||
setError('Erreur lors du chargement des utilisateurs');
|
||||
console.error(requestError);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadUsers();
|
||||
}, []);
|
||||
|
||||
const handleDeleteUser = async (userId) => {
|
||||
if (!window.confirm('Êtes-vous sûr de vouloir supprimer cet utilisateur ?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await userApi.deleteUser(userId);
|
||||
setUsers(users.filter(user => user.id !== userId));
|
||||
} catch (err) {
|
||||
setError('Erreur lors de la suppression de l\'utilisateur');
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMakeAdmin = async (userId) => {
|
||||
try {
|
||||
await userApi.promoteUser(userId);
|
||||
setUsers(users.map(user =>
|
||||
user.id === userId ? { ...user, is_staff: true } : user
|
||||
));
|
||||
} catch (err) {
|
||||
setError('Erreur lors de la promotion de l\'utilisateur');
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="card admin-page">
|
||||
<h2>Gestion des comptes</h2>
|
||||
<p>Chargement des utilisateurs...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card admin-page">
|
||||
<h2>Gestion des comptes</h2>
|
||||
{error && <div className="alert alert-danger">{error}</div>}
|
||||
|
||||
<div className="admin-toolbar">
|
||||
<button className="btn btn-primary" onClick={() => navigate('/invite')}>
|
||||
Nouvel utilisateur
|
||||
</button>
|
||||
<button className="btn btn-secondary">
|
||||
Exporter les utilisateurs
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="table-responsive">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nom d'utilisateur</th>
|
||||
<th>Email</th>
|
||||
<th>Rôle</th>
|
||||
<th>Inscrit le</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((user) => (
|
||||
<tr key={user.id}>
|
||||
<td>{user.username}</td>
|
||||
<td>{user.email}</td>
|
||||
<td>
|
||||
{user.is_admin ? (
|
||||
<span style={{ color: 'var(--accent)', fontWeight: '500' }}>Administrateur</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--text)', fontWeight: '500' }}>Utilisateur</span>
|
||||
)}
|
||||
</td>
|
||||
<td>{new Date(user.date_joined).toLocaleDateString('fr-FR')}</td>
|
||||
<td>
|
||||
<div className="btn-group">
|
||||
{!user.is_admin && (
|
||||
<button
|
||||
onClick={() => handleMakeAdmin(user.id)}
|
||||
className="btn btn-secondary btn-small"
|
||||
>
|
||||
Promouvoir
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDeleteUser(user.id)}
|
||||
className="btn btn-danger btn-small"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminUsersPage;
|
||||
48
frontend/src/pages/HomePage.css
Normal file
48
frontend/src/pages/HomePage.css
Normal file
@ -0,0 +1,48 @@
|
||||
.home-container {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.9rem 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.info-item:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.info-item strong {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.info-item span {
|
||||
color: var(--text);
|
||||
font-weight: 650;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.info-item {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.info-item span {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
57
frontend/src/pages/HomePage.jsx
Normal file
57
frontend/src/pages/HomePage.jsx
Normal file
@ -0,0 +1,57 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import './HomePage.css';
|
||||
|
||||
const HomePage = () => {
|
||||
const { user } = useAuth();
|
||||
const [userData, setUserData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
// Simuler le chargement des données utilisateur
|
||||
setTimeout(() => {
|
||||
setUserData(user);
|
||||
setLoading(false);
|
||||
}, 500);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="loading">
|
||||
<div className="spinner"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="home-container">
|
||||
<div className="profile-card">
|
||||
<h2>Profil Utilisateur</h2>
|
||||
{userData && (
|
||||
<div className="user-info">
|
||||
<div className="info-item">
|
||||
<strong>Nom d'utilisateur:</strong>
|
||||
<span>{userData.username}</span>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<strong>Email:</strong>
|
||||
<span>{userData.email || 'Non spécifié'}</span>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<strong>Admin:</strong>
|
||||
<span>{userData.is_admin ? 'Oui' : 'Non'}</span>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<strong>Date d'inscription:</strong>
|
||||
<span>{userData.date_joined || 'Non spécifié'}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomePage;
|
||||
24
frontend/src/pages/LoginPage.css
Normal file
24
frontend/src/pages/LoginPage.css
Normal file
@ -0,0 +1,24 @@
|
||||
.login-container {
|
||||
display: grid;
|
||||
min-height: min(620px, calc(100vh - 190px));
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: min(100%, 460px);
|
||||
padding: clamp(1.35rem, 5vw, 2.5rem);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-card h2 {
|
||||
margin-bottom: 0.45rem;
|
||||
}
|
||||
|
||||
.login-card .form-group {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.login-card .btn {
|
||||
width: 100%;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
65
frontend/src/pages/LoginPage.jsx
Normal file
65
frontend/src/pages/LoginPage.jsx
Normal file
@ -0,0 +1,65 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import './LoginPage.css';
|
||||
|
||||
const LoginPage = () => {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const from = location.state?.from?.pathname || "/";
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
try {
|
||||
await login(username, password);
|
||||
navigate(from, { replace: true });
|
||||
} catch (err) {
|
||||
setError('Nom d\'utilisateur ou mot de passe incorrect');
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-container">
|
||||
<div className="card login-card">
|
||||
<h2>Connexion</h2>
|
||||
{error && <div className="alert alert-danger">{error}</div>}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="username">Nom d'utilisateur:</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="form-control"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="password">Mot de passe:</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="form-control"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Se connecter
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
49
frontend/src/pages/RegisterPage.jsx
Normal file
49
frontend/src/pages/RegisterPage.jsx
Normal file
@ -0,0 +1,49 @@
|
||||
import { useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import api from '../api/axios';
|
||||
import './LoginPage.css';
|
||||
|
||||
const RegisterPage = () => {
|
||||
const invite = new URLSearchParams(useLocation().search).get('invite') || '';
|
||||
const navigate = useNavigate();
|
||||
const [form, setForm] = useState({ username: '', email: '', password: '' });
|
||||
const [error, setError] = useState('');
|
||||
const [isCreated, setIsCreated] = useState(false);
|
||||
|
||||
const handleSubmit = async (event) => {
|
||||
event.preventDefault();
|
||||
setError('');
|
||||
try {
|
||||
await api.post('/users/register/', { ...form, invite });
|
||||
setIsCreated(true);
|
||||
} catch (requestError) {
|
||||
const detail = requestError.response?.data;
|
||||
setError(detail?.invite?.[0] || detail?.username?.[0] || 'Impossible de créer le compte.');
|
||||
}
|
||||
};
|
||||
|
||||
if (!invite) {
|
||||
return <div className="login-container"><div className="card login-card"><h2>Lien invalide</h2><p>Un lien d’invitation est nécessaire pour créer un compte.</p></div></div>;
|
||||
}
|
||||
|
||||
if (isCreated) {
|
||||
return <div className="login-container"><div className="card login-card"><h2>Compte créé</h2><p>Votre compte est prêt. Vous pouvez maintenant vous connecter.</p><button className="btn btn-primary" onClick={() => navigate('/login')}>Se connecter</button></div></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-container">
|
||||
<div className="card login-card">
|
||||
<h2>Créer un compte</h2>
|
||||
{error && <div className="alert alert-danger">{error}</div>}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group"><label htmlFor="username">Nom d'utilisateur</label><input id="username" required value={form.username} onChange={(event) => setForm({ ...form, username: event.target.value })} /></div>
|
||||
<div className="form-group"><label htmlFor="email">Email</label><input id="email" type="email" required value={form.email} onChange={(event) => setForm({ ...form, email: event.target.value })} /></div>
|
||||
<div className="form-group"><label htmlFor="password">Mot de passe</label><input id="password" type="password" required minLength="8" value={form.password} onChange={(event) => setForm({ ...form, password: event.target.value })} /></div>
|
||||
<button type="submit" className="btn btn-primary">Créer mon compte</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RegisterPage;
|
||||
164
frontend/src/pages/TimeClockPage.css
Normal file
164
frontend/src/pages/TimeClockPage.css
Normal file
@ -0,0 +1,164 @@
|
||||
.time-clock-page {
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
.time-clock-intro {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.page-eyebrow {
|
||||
color: var(--accent);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.time-clock-intro h2 {
|
||||
margin-top: 0.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.time-clock-intro p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.point-button {
|
||||
width: 100%;
|
||||
min-height: 54px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.scanner-panel {
|
||||
position: relative;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
overflow: hidden;
|
||||
padding: 1rem;
|
||||
background: var(--surface-muted);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.scanner-video {
|
||||
width: 100%;
|
||||
max-height: 60vh;
|
||||
min-height: 260px;
|
||||
object-fit: cover;
|
||||
background: #241b16;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.scanner-frame {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: min(68%, 280px);
|
||||
aspect-ratio: 1;
|
||||
border: 3px solid #fff;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 0 0 999px rgba(28, 20, 15, 0.2);
|
||||
transform: translate(-50%, -55%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.scan-success {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.scan-success span {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* New styles for time tracking */
|
||||
.time-tracking-section {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.current-time-display {
|
||||
text-align: center;
|
||||
margin: 20px 0;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.time-entry-form {
|
||||
background-color: #f8f9fa;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.time-entry-form h4 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.time-entries-list {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.time-entries-list h4 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.table-responsive {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: #f8f9fa;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 5px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.total-hours {
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
padding: 15px;
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
328
frontend/src/pages/TimeClockPage.jsx
Normal file
328
frontend/src/pages/TimeClockPage.jsx
Normal file
@ -0,0 +1,328 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import './TimeClockPage.css';
|
||||
import { timeApi } from '../api/time';
|
||||
|
||||
const TimeClockPage = () => {
|
||||
const videoRef = useRef(null);
|
||||
const streamRef = useRef(null);
|
||||
const scanFrameRef = useRef(null);
|
||||
const [isScanning, setIsScanning] = useState(false);
|
||||
const [scanResult, setScanResult] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [currentTime, setCurrentTime] = useState(new Date());
|
||||
const [timeEntries, setTimeEntries] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [newEntry, setNewEntry] = useState({
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
start_time: '',
|
||||
end_time: ''
|
||||
});
|
||||
const [editingEntry, setEditingEntry] = useState(null);
|
||||
const [editForm, setEditForm] = useState({
|
||||
start_time: '',
|
||||
end_time: ''
|
||||
});
|
||||
|
||||
// Update current time every second
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setCurrentTime(new Date());
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
// Load time entries when component mounts
|
||||
useEffect(() => {
|
||||
loadTimeEntries();
|
||||
}, []);
|
||||
|
||||
const loadTimeEntries = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const entries = await timeApi.getTimeEntries();
|
||||
setTimeEntries(entries);
|
||||
setError('');
|
||||
} catch (err) {
|
||||
setError('Erreur lors du chargement des pointages');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddEntry = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!newEntry.start_time || !newEntry.end_time) {
|
||||
setError('Veuillez remplir tous les champs');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await timeApi.createTimeEntry(newEntry);
|
||||
setNewEntry({
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
start_time: '',
|
||||
end_time: ''
|
||||
});
|
||||
loadTimeEntries(); // Refresh the list
|
||||
setError('');
|
||||
} catch (err) {
|
||||
setError('Erreur lors de l\'ajout du pointage');
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditEntry = (entry) => {
|
||||
setEditingEntry(entry);
|
||||
setEditForm({
|
||||
start_time: entry.start_time,
|
||||
end_time: entry.end_time
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateEntry = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!editForm.start_time || !editForm.end_time) {
|
||||
setError('Veuillez remplir tous les champs');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await timeApi.updateTimeEntry(editingEntry.id, editForm);
|
||||
setEditingEntry(null);
|
||||
setEditForm({ start_time: '', end_time: '' });
|
||||
loadTimeEntries(); // Refresh the list
|
||||
setError('');
|
||||
} catch (err) {
|
||||
setError('Erreur lors de la mise à jour du pointage');
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteEntry = async (id) => {
|
||||
if (!window.confirm('Êtes-vous sûr de vouloir supprimer ce pointage ?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await timeApi.deleteTimeEntry(id);
|
||||
loadTimeEntries(); // Refresh the list
|
||||
setError('');
|
||||
} catch (err) {
|
||||
setError('Erreur lors de la suppression du pointage');
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const stopScanner = () => {
|
||||
if (scanFrameRef.current) {
|
||||
cancelAnimationFrame(scanFrameRef.current);
|
||||
scanFrameRef.current = null;
|
||||
}
|
||||
streamRef.current?.getTracks().forEach((track) => track.stop());
|
||||
streamRef.current = null;
|
||||
if (videoRef.current) {
|
||||
videoRef.current.srcObject = null;
|
||||
}
|
||||
setIsScanning(false);
|
||||
};
|
||||
|
||||
useEffect(() => () => stopScanner(), []);
|
||||
|
||||
const startScanner = async () => {
|
||||
setError('');
|
||||
setScanResult('');
|
||||
|
||||
if (!('BarcodeDetector' in window)) {
|
||||
setError('La lecture QR n’est pas prise en charge par ce navigateur. Utilisez Chrome ou Edge à jour.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const supportedFormats = await window.BarcodeDetector.getSupportedFormats();
|
||||
if (!supportedFormats.includes('qr_code')) {
|
||||
setError('La lecture des QR codes n’est pas disponible sur cet appareil.');
|
||||
return;
|
||||
}
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: { ideal: 'environment' } },
|
||||
audio: false,
|
||||
});
|
||||
streamRef.current = stream;
|
||||
videoRef.current.srcObject = stream;
|
||||
await videoRef.current.play();
|
||||
setIsScanning(true);
|
||||
|
||||
const detector = new window.BarcodeDetector({ formats: ['qr_code'] });
|
||||
const scan = async () => {
|
||||
if (!videoRef.current || videoRef.current.readyState < 2) {
|
||||
scanFrameRef.current = requestAnimationFrame(scan);
|
||||
return;
|
||||
}
|
||||
|
||||
const codes = await detector.detect(videoRef.current);
|
||||
if (codes.length > 0) {
|
||||
setScanResult(codes[0].rawValue);
|
||||
stopScanner();
|
||||
return;
|
||||
}
|
||||
scanFrameRef.current = requestAnimationFrame(scan);
|
||||
};
|
||||
scanFrameRef.current = requestAnimationFrame(scan);
|
||||
} catch (scannerError) {
|
||||
setError('Impossible d’accéder à la caméra. Vérifiez les permissions de votre navigateur.');
|
||||
console.error(scannerError);
|
||||
stopScanner();
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (timeString) => {
|
||||
if (!timeString) return '';
|
||||
const [hours, minutes] = timeString.split(':');
|
||||
return `${hours}h${minutes}`;
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('fr-FR');
|
||||
};
|
||||
|
||||
const calculateTotalHours = () => {
|
||||
return timeEntries.reduce((total, entry) => total + (entry.duration || 0), 0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card time-clock-page">
|
||||
<div className="time-clock-intro">
|
||||
<span className="page-eyebrow">Présence</span>
|
||||
<h2>Pointage</h2>
|
||||
<p>Scannez le QR code présent sur votre lieu de travail pour enregistrer votre passage.</p>
|
||||
</div>
|
||||
|
||||
{error && <div className="alert alert-danger">{error}</div>}
|
||||
|
||||
{isScanning ? (
|
||||
<div className="scanner-panel">
|
||||
<video ref={videoRef} className="scanner-video" playsInline muted />
|
||||
<div className="scanner-frame" aria-hidden="true" />
|
||||
<button type="button" className="btn btn-secondary" onClick={stopScanner}>Annuler</button>
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" className="btn btn-primary point-button" onClick={startScanner}>
|
||||
Pointer avec un QR code
|
||||
</button>
|
||||
)}
|
||||
|
||||
{scanResult && (
|
||||
<div className="alert alert-success scan-success">
|
||||
QR code détecté : <strong>{scanResult}</strong>
|
||||
<span>Le pointage sera relié à votre session dans la prochaine étape.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Time tracking section */}
|
||||
<div className="time-tracking-section">
|
||||
<h3>Historique des pointages</h3>
|
||||
|
||||
{/* Current time display */}
|
||||
<div className="current-time-display">
|
||||
<p>Heure actuelle: {currentTime.toLocaleTimeString('fr-FR')}</p>
|
||||
</div>
|
||||
|
||||
{/* Add new entry form */}
|
||||
<div className="time-entry-form">
|
||||
<h4>Ajouter un pointage manuel</h4>
|
||||
<form onSubmit={handleAddEntry}>
|
||||
<div className="form-group">
|
||||
<label>Date:</label>
|
||||
<input
|
||||
type="date"
|
||||
value={newEntry.date}
|
||||
onChange={(e) => setNewEntry({...newEntry, date: e.target.value})}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Heure d'entrée:</label>
|
||||
<input
|
||||
type="time"
|
||||
value={newEntry.start_time}
|
||||
onChange={(e) => setNewEntry({...newEntry, start_time: e.target.value})}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Heure de sortie:</label>
|
||||
<input
|
||||
type="time"
|
||||
value={newEntry.end_time}
|
||||
onChange={(e) => setNewEntry({...newEntry, end_time: e.target.value})}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary">Ajouter le pointage</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Time entries list */}
|
||||
<div className="time-entries-list">
|
||||
<h4>Historique des pointages</h4>
|
||||
{loading ? (
|
||||
<p>Chargement des pointages...</p>
|
||||
) : timeEntries.length === 0 ? (
|
||||
<p>Aucun pointage enregistré</p>
|
||||
) : (
|
||||
<div className="table-responsive">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Entrée</th>
|
||||
<th>Sortie</th>
|
||||
<th>Durée</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{timeEntries.map((entry) => (
|
||||
<tr key={entry.id}>
|
||||
<td>{formatDate(entry.date)}</td>
|
||||
<td>{formatTime(entry.start_time)}</td>
|
||||
<td>{formatTime(entry.end_time)}</td>
|
||||
<td>{entry.duration ? `${entry.duration.toFixed(2)}h` : '0.00h'}</td>
|
||||
<td>
|
||||
<div className="btn-group">
|
||||
<button
|
||||
onClick={() => handleEditEntry(entry)}
|
||||
className="btn btn-secondary btn-small"
|
||||
>
|
||||
Modifier
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteEntry(entry.id)}
|
||||
className="btn btn-danger btn-small"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Total hours */}
|
||||
<div className="total-hours">
|
||||
<h4>Total des heures: {calculateTotalHours().toFixed(2)}h</h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimeClockPage;
|
||||
137
frontend/src/pages/WeeklySummaryPage.jsx
Normal file
137
frontend/src/pages/WeeklySummaryPage.jsx
Normal file
@ -0,0 +1,137 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { timeApi } from '../api/time';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const WeeklySummaryPage = () => {
|
||||
const [weeklySummary, setWeeklySummary] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
loadWeeklySummary();
|
||||
}, []);
|
||||
|
||||
const loadWeeklySummary = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const summary = await timeApi.getWeeklySummary();
|
||||
setWeeklySummary(summary);
|
||||
setError('');
|
||||
} catch (err) {
|
||||
setError('Erreur lors du chargement du récapitulatif hebdomadaire');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (timeString) => {
|
||||
if (!timeString) return '';
|
||||
const [hours, minutes] = timeString.split(':');
|
||||
return `${hours}h${minutes}`;
|
||||
};
|
||||
|
||||
const getDayName = (dayIndex) => {
|
||||
const days = ['Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi', 'Dimanche'];
|
||||
return days[dayIndex];
|
||||
};
|
||||
|
||||
const getDayAbbreviation = (dayName) => {
|
||||
const abbreviations = {
|
||||
'Monday': 'Lun',
|
||||
'Tuesday': 'Mar',
|
||||
'Wednesday': 'Mer',
|
||||
'Thursday': 'Jeu',
|
||||
'Friday': 'Ven',
|
||||
'Saturday': 'Sam',
|
||||
'Sunday': 'Dim'
|
||||
};
|
||||
return abbreviations[dayName] || dayName;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="card weekly-summary-page">
|
||||
<h2>Récapitulatif hebdomadaire</h2>
|
||||
<p>Chargement...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="card weekly-summary-page">
|
||||
<h2>Récapitulatif hebdomadaire</h2>
|
||||
<div className="alert alert-danger">{error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!weeklySummary) {
|
||||
return (
|
||||
<div className="card weekly-summary-page">
|
||||
<h2>Récapitulatif hebdomadaire</h2>
|
||||
<p>Aucune donnée disponible</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card weekly-summary-page">
|
||||
<h2>Récapitulatif hebdomadaire</h2>
|
||||
|
||||
<div className="weekly-summary-header">
|
||||
<h3>{weeklySummary.username}</h3>
|
||||
<p>Total des heures cette semaine: <strong>{weeklySummary.total_hours}h</strong></p>
|
||||
</div>
|
||||
|
||||
<div className="weekly-schedule">
|
||||
<h3>Emploi du temps hebdomadaire</h3>
|
||||
<div className="schedule-grid">
|
||||
{weeklySummary.days.map((day, index) => (
|
||||
<div key={index} className="schedule-day">
|
||||
<div className="day-header">
|
||||
<span className="day-name">{getDayName(index)}</span>
|
||||
<span className="day-date">{new Date(day.date).toLocaleDateString('fr-FR')}</span>
|
||||
</div>
|
||||
<div className="day-content">
|
||||
{day.start_time ? (
|
||||
<>
|
||||
<div className="time-entry">
|
||||
<span className="time-label">Entrée:</span>
|
||||
<span className="time-value">{formatTime(day.start_time)}</span>
|
||||
</div>
|
||||
<div className="time-entry">
|
||||
<span className="time-label">Sortie:</span>
|
||||
<span className="time-value">{formatTime(day.end_time)}</span>
|
||||
</div>
|
||||
<div className="time-entry">
|
||||
<span className="time-label">Durée:</span>
|
||||
<span className="time-value">{day.duration.toFixed(2)}h</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="no-entry">
|
||||
<span className="no-entry-text">Aucun pointage</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="weekly-summary-footer">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => navigate('/time-clock')}
|
||||
>
|
||||
Gérer les pointages
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WeeklySummaryPage;
|
||||
70
frontend/src/services/attendanceStorage.js
Normal file
70
frontend/src/services/attendanceStorage.js
Normal file
@ -0,0 +1,70 @@
|
||||
// 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();
|
||||
12
frontend/vite.config.js
Normal file
12
frontend/vite.config.js
Normal file
@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
|
||||
preview: {
|
||||
allowedHosts: [
|
||||
'boulangerie.louismazin.ovh'
|
||||
]
|
||||
}
|
||||
})
|
||||
73
start.sh
Normal file
73
start.sh
Normal file
@ -0,0 +1,73 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
cd /home/container
|
||||
|
||||
echo "======================================"
|
||||
echo " Boulangerie Web"
|
||||
echo "======================================"
|
||||
|
||||
echo "Python : $(python3 --version)"
|
||||
echo "Node : $(node --version)"
|
||||
echo "npm : $(npm --version)"
|
||||
|
||||
# --------------------------------------
|
||||
# Backend
|
||||
# --------------------------------------
|
||||
|
||||
cd /home/container/backend
|
||||
|
||||
python3 manage.py check
|
||||
|
||||
echo "Démarrage Django sur :1029"
|
||||
|
||||
python3 manage.py migrate --noinput
|
||||
|
||||
python3 manage.py runserver 0.0.0.0:1029 &
|
||||
BACKEND_PID=$!
|
||||
|
||||
# --------------------------------------
|
||||
# Frontend
|
||||
# --------------------------------------
|
||||
|
||||
cd /home/container/frontend
|
||||
|
||||
if [ ! -x "node_modules/.bin/vite" ]; then
|
||||
echo "node_modules absent → installation npm..."
|
||||
npm ci
|
||||
fi
|
||||
|
||||
export VITE_API_URL="/api"
|
||||
|
||||
echo "Build React..."
|
||||
|
||||
npm run build
|
||||
|
||||
echo "Démarrage frontend sur :1028"
|
||||
|
||||
npm run preview -- --host 0.0.0.0 --port 1028 &
|
||||
FRONTEND_PID=$!
|
||||
|
||||
# --------------------------------------
|
||||
# Arrêt propre
|
||||
# --------------------------------------
|
||||
|
||||
cleanup() {
|
||||
echo "Arrêt des applications..."
|
||||
|
||||
kill "$BACKEND_PID" 2>/dev/null || true
|
||||
kill "$FRONTEND_PID" 2>/dev/null || true
|
||||
|
||||
wait "$BACKEND_PID" 2>/dev/null || true
|
||||
wait "$FRONTEND_PID" 2>/dev/null || true
|
||||
}
|
||||
|
||||
trap cleanup SIGTERM SIGINT EXIT
|
||||
|
||||
echo "======================================"
|
||||
echo " Frontend : :1028"
|
||||
echo " Backend : :1029"
|
||||
echo "======================================"
|
||||
|
||||
wait -n "$BACKEND_PID" "$FRONTEND_PID"
|
||||
Loading…
x
Reference in New Issue
Block a user