This commit is contained in:
Louis Mazin 2026-09-13 18:56:33 +02:00
commit 5a56f5383d
65 changed files with 5816 additions and 0 deletions

0
backend/core/__init__.py Normal file
View File

16
backend/core/asgi.py Normal file
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,7 @@
django
djangorestframework
djangorestframework-simplejwt
django-cors-headers
mysqlclient
python-decouple
dj-database-url

View File

View File

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View File

@ -0,0 +1,5 @@
from django.apps import AppConfig
class TimeTrackingConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'time_tracking'

View 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')},
},
),
]

View 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')

View 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

View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View 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'),
]

View 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)

View File

39
backend/users/apps.py Normal file
View 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}')

View 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}"')
)

View 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}"')
)

View 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)),
],
),
]

View File

17
backend/users/models.py Normal file
View 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()

View 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 dinvitation est invalide.') from error
if not invitation.is_valid:
raise serializers.ValidationError('Ce lien dinvitation 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 dinvitation 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
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

12
backend/users/urls.py Normal file
View 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
View 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
View File

@ -0,0 +1,2 @@
node_modules
dist

8
frontend/.oxlintrc.json Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

25
frontend/package.json Normal file
View 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
View 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
View 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>&copy; 2026 Le Fournil du Bocage. Tous droits réservés.</p>
</footer>
</div>
);
}
export default App;

45
frontend/src/api/axios.js Normal file
View 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
View 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
View 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);
}
},
};

View 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;
}
}

View 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;

View 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%;
}
}

View 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;

View 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;

View 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;
}
}

View 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;

View 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;
}
}

View 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;

View 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%;
}
}

View 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;

View 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
View 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
View 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>,
)

View 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;

View 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;

View 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;
}
}

View 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;

View 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;
}

View 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;

View 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 dinvitation 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;

View 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;
}

View 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 nest 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 nest 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 daccé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;

View 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;

View 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
View 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
View 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"