From 5a56f5383d055a17ee9110b996a3ac5242e5edc4 Mon Sep 17 00:00:00 2001 From: Louis Mazin Date: Sun, 13 Sep 2026 18:56:33 +0200 Subject: [PATCH] init --- backend/core/__init__.py | 0 backend/core/asgi.py | 16 + backend/core/settings.py | 155 ++ backend/core/urls.py | 30 + backend/core/wsgi.py | 16 + backend/manage.py | 22 + backend/requirements.txt | 7 + backend/time_tracking/__init__.py | 0 backend/time_tracking/admin.py | 3 + backend/time_tracking/apps.py | 5 + .../time_tracking/migrations/0001_initial.py | 33 + backend/time_tracking/migrations/__init__.py | 0 backend/time_tracking/models.py | 32 + backend/time_tracking/serializers.py | 43 + backend/time_tracking/tests.py | 3 + backend/time_tracking/urls.py | 18 + backend/time_tracking/views.py | 189 ++ backend/users/__init__.py | 0 backend/users/apps.py | 39 + .../commands/check_and_create_admin.py | 32 + .../users/management/commands/create_admin.py | 38 + backend/users/migrations/0001_invitation.py | 26 + backend/users/migrations/__init__.py | 0 backend/users/models.py | 17 + backend/users/serializers.py | 46 + backend/users/tests.py | 3 + backend/users/urls.py | 12 + backend/users/views.py | 109 ++ frontend/.dockerignore | 2 + frontend/.oxlintrc.json | 8 + frontend/index.html | 17 + frontend/package-lock.json | 1652 +++++++++++++++++ frontend/package.json | 25 + frontend/src/App.css | 307 +++ frontend/src/App.jsx | 54 + frontend/src/api/axios.js | 45 + frontend/src/api/time.js | 73 + frontend/src/api/users.js | 74 + frontend/src/components/AttendanceApp.css | 69 + frontend/src/components/AttendanceApp.jsx | 39 + frontend/src/components/Header.css | 220 +++ frontend/src/components/Header.jsx | 92 + frontend/src/components/ProtectedRoute.jsx | 21 + frontend/src/components/RecapTab.css | 68 + frontend/src/components/RecapTab.jsx | 56 + frontend/src/components/TimeClockQR.css | 303 +++ frontend/src/components/TimeClockQR.jsx | 203 ++ frontend/src/components/WeeklySchedule.css | 121 ++ frontend/src/components/WeeklySchedule.jsx | 102 + frontend/src/context/AuthContext.jsx | 67 + frontend/src/main.css | 47 + frontend/src/main.jsx | 16 + frontend/src/pages/AdminInvitePage.jsx | 85 + frontend/src/pages/AdminUsersPage.jsx | 129 ++ frontend/src/pages/HomePage.css | 48 + frontend/src/pages/HomePage.jsx | 57 + frontend/src/pages/LoginPage.css | 24 + frontend/src/pages/LoginPage.jsx | 65 + frontend/src/pages/RegisterPage.jsx | 49 + frontend/src/pages/TimeClockPage.css | 164 ++ frontend/src/pages/TimeClockPage.jsx | 328 ++++ frontend/src/pages/WeeklySummaryPage.jsx | 137 ++ frontend/src/services/attendanceStorage.js | 70 + frontend/vite.config.js | 12 + start.sh | 73 + 65 files changed, 5816 insertions(+) create mode 100644 backend/core/__init__.py create mode 100644 backend/core/asgi.py create mode 100644 backend/core/settings.py create mode 100644 backend/core/urls.py create mode 100644 backend/core/wsgi.py create mode 100644 backend/manage.py create mode 100644 backend/requirements.txt create mode 100644 backend/time_tracking/__init__.py create mode 100644 backend/time_tracking/admin.py create mode 100644 backend/time_tracking/apps.py create mode 100644 backend/time_tracking/migrations/0001_initial.py create mode 100644 backend/time_tracking/migrations/__init__.py create mode 100644 backend/time_tracking/models.py create mode 100644 backend/time_tracking/serializers.py create mode 100644 backend/time_tracking/tests.py create mode 100644 backend/time_tracking/urls.py create mode 100644 backend/time_tracking/views.py create mode 100644 backend/users/__init__.py create mode 100644 backend/users/apps.py create mode 100644 backend/users/management/commands/check_and_create_admin.py create mode 100644 backend/users/management/commands/create_admin.py create mode 100644 backend/users/migrations/0001_invitation.py create mode 100644 backend/users/migrations/__init__.py create mode 100644 backend/users/models.py create mode 100644 backend/users/serializers.py create mode 100644 backend/users/tests.py create mode 100644 backend/users/urls.py create mode 100644 backend/users/views.py create mode 100644 frontend/.dockerignore create mode 100644 frontend/.oxlintrc.json create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/App.css create mode 100644 frontend/src/App.jsx create mode 100644 frontend/src/api/axios.js create mode 100644 frontend/src/api/time.js create mode 100644 frontend/src/api/users.js create mode 100644 frontend/src/components/AttendanceApp.css create mode 100644 frontend/src/components/AttendanceApp.jsx create mode 100644 frontend/src/components/Header.css create mode 100644 frontend/src/components/Header.jsx create mode 100644 frontend/src/components/ProtectedRoute.jsx create mode 100644 frontend/src/components/RecapTab.css create mode 100644 frontend/src/components/RecapTab.jsx create mode 100644 frontend/src/components/TimeClockQR.css create mode 100644 frontend/src/components/TimeClockQR.jsx create mode 100644 frontend/src/components/WeeklySchedule.css create mode 100644 frontend/src/components/WeeklySchedule.jsx create mode 100644 frontend/src/context/AuthContext.jsx create mode 100644 frontend/src/main.css create mode 100644 frontend/src/main.jsx create mode 100644 frontend/src/pages/AdminInvitePage.jsx create mode 100644 frontend/src/pages/AdminUsersPage.jsx create mode 100644 frontend/src/pages/HomePage.css create mode 100644 frontend/src/pages/HomePage.jsx create mode 100644 frontend/src/pages/LoginPage.css create mode 100644 frontend/src/pages/LoginPage.jsx create mode 100644 frontend/src/pages/RegisterPage.jsx create mode 100644 frontend/src/pages/TimeClockPage.css create mode 100644 frontend/src/pages/TimeClockPage.jsx create mode 100644 frontend/src/pages/WeeklySummaryPage.jsx create mode 100644 frontend/src/services/attendanceStorage.js create mode 100644 frontend/vite.config.js create mode 100644 start.sh diff --git a/backend/core/__init__.py b/backend/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/asgi.py b/backend/core/asgi.py new file mode 100644 index 0000000..e36e2c8 --- /dev/null +++ b/backend/core/asgi.py @@ -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() diff --git a/backend/core/settings.py b/backend/core/settings.py new file mode 100644 index 0000000..ceb4882 --- /dev/null +++ b/backend/core/settings.py @@ -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' diff --git a/backend/core/urls.py b/backend/core/urls.py new file mode 100644 index 0000000..30c45ef --- /dev/null +++ b/backend/core/urls.py @@ -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')), +] diff --git a/backend/core/wsgi.py b/backend/core/wsgi.py new file mode 100644 index 0000000..050d8bc --- /dev/null +++ b/backend/core/wsgi.py @@ -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() diff --git a/backend/manage.py b/backend/manage.py new file mode 100644 index 0000000..a98ffa1 --- /dev/null +++ b/backend/manage.py @@ -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() \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..3300a9c --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,7 @@ +django +djangorestframework +djangorestframework-simplejwt +django-cors-headers +mysqlclient +python-decouple +dj-database-url diff --git a/backend/time_tracking/__init__.py b/backend/time_tracking/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/time_tracking/admin.py b/backend/time_tracking/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/backend/time_tracking/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/backend/time_tracking/apps.py b/backend/time_tracking/apps.py new file mode 100644 index 0000000..e6cc594 --- /dev/null +++ b/backend/time_tracking/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + +class TimeTrackingConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'time_tracking' diff --git a/backend/time_tracking/migrations/0001_initial.py b/backend/time_tracking/migrations/0001_initial.py new file mode 100644 index 0000000..9a94ba0 --- /dev/null +++ b/backend/time_tracking/migrations/0001_initial.py @@ -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')}, + }, + ), + ] diff --git a/backend/time_tracking/migrations/__init__.py b/backend/time_tracking/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/time_tracking/models.py b/backend/time_tracking/models.py new file mode 100644 index 0000000..29c8c00 --- /dev/null +++ b/backend/time_tracking/models.py @@ -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') diff --git a/backend/time_tracking/serializers.py b/backend/time_tracking/serializers.py new file mode 100644 index 0000000..a6fd3af --- /dev/null +++ b/backend/time_tracking/serializers.py @@ -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 \ No newline at end of file diff --git a/backend/time_tracking/tests.py b/backend/time_tracking/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/backend/time_tracking/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/time_tracking/urls.py b/backend/time_tracking/urls.py new file mode 100644 index 0000000..7cae966 --- /dev/null +++ b/backend/time_tracking/urls.py @@ -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//', TimeEntryDetailView.as_view(), name='time-entry-detail'), + path('entries//update/', TimeEntryUpdateView.as_view(), name='time-entry-update'), + path('weekly-summary/', UserWeeklySummaryView.as_view(), name='user-weekly-summary'), + path('weekly-summary//', WeeklySummaryView.as_view(), name='weekly-summary'), +] \ No newline at end of file diff --git a/backend/time_tracking/views.py b/backend/time_tracking/views.py new file mode 100644 index 0000000..dddc4a2 --- /dev/null +++ b/backend/time_tracking/views.py @@ -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) diff --git a/backend/users/__init__.py b/backend/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/users/apps.py b/backend/users/apps.py new file mode 100644 index 0000000..2b447c9 --- /dev/null +++ b/backend/users/apps.py @@ -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}') diff --git a/backend/users/management/commands/check_and_create_admin.py b/backend/users/management/commands/check_and_create_admin.py new file mode 100644 index 0000000..2fec55a --- /dev/null +++ b/backend/users/management/commands/check_and_create_admin.py @@ -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}"') + ) \ No newline at end of file diff --git a/backend/users/management/commands/create_admin.py b/backend/users/management/commands/create_admin.py new file mode 100644 index 0000000..367efa8 --- /dev/null +++ b/backend/users/management/commands/create_admin.py @@ -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}"') + ) \ No newline at end of file diff --git a/backend/users/migrations/0001_invitation.py b/backend/users/migrations/0001_invitation.py new file mode 100644 index 0000000..afecb80 --- /dev/null +++ b/backend/users/migrations/0001_invitation.py @@ -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)), + ], + ), + ] diff --git a/backend/users/migrations/__init__.py b/backend/users/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/users/models.py b/backend/users/models.py new file mode 100644 index 0000000..b56459f --- /dev/null +++ b/backend/users/models.py @@ -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() diff --git a/backend/users/serializers.py b/backend/users/serializers.py new file mode 100644 index 0000000..ae7aaa4 --- /dev/null +++ b/backend/users/serializers.py @@ -0,0 +1,46 @@ +from django.contrib.auth.models import User +from django.db import transaction +from django.utils import timezone +from rest_framework import serializers + +from .models import Invitation + + +class UserSerializer(serializers.ModelSerializer): + is_admin = serializers.SerializerMethodField() + + class Meta: + model = User + fields = ['id', 'username', 'email', 'first_name', 'last_name', 'is_admin', 'date_joined'] + + def get_is_admin(self, obj): + return obj.is_superuser or obj.is_staff + + +class RegisterSerializer(serializers.ModelSerializer): + password = serializers.CharField(write_only=True) + invite = serializers.UUIDField(write_only=True) + + class Meta: + model = User + fields = ['username', 'password', 'email', 'first_name', 'last_name', 'invite'] + + def validate_invite(self, value): + try: + invitation = Invitation.objects.get(token=value) + except Invitation.DoesNotExist as error: + raise serializers.ValidationError('Ce lien d’invitation est invalide.') from error + if not invitation.is_valid: + raise serializers.ValidationError('Ce lien d’invitation est expire ou deja utilise.') + return value + + def create(self, validated_data): + invitation_token = validated_data.pop('invite') + with transaction.atomic(): + invitation = Invitation.objects.select_for_update().get(token=invitation_token) + if not invitation.is_valid: + raise serializers.ValidationError({'invite': 'Ce lien d’invitation est expire ou deja utilise.'}) + user = User.objects.create_user(**validated_data) + invitation.used_at = timezone.now() + invitation.save(update_fields=['used_at']) + return user diff --git a/backend/users/tests.py b/backend/users/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/backend/users/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/users/urls.py b/backend/users/urls.py new file mode 100644 index 0000000..740465e --- /dev/null +++ b/backend/users/urls.py @@ -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('/', UserDetailView.as_view(), name='user-detail'), + path('/promote/', UserPromoteView.as_view(), name='user-promote'), + path('/delete/', UserDeleteView.as_view(), name='user-delete'), + path('invitations/', InvitationCreateView.as_view(), name='invitation-create'), +] diff --git a/backend/users/views.py b/backend/users/views.py new file mode 100644 index 0000000..6c1c471 --- /dev/null +++ b/backend/users/views.py @@ -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) diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..f06235c --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json new file mode 100644 index 0000000..1255078 --- /dev/null +++ b/frontend/.oxlintrc.json @@ -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 }] + } +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..5808160 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,17 @@ + + + + + + + + + + + Le Fournil du Bocage + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..264fec1 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1652 @@ +{ + "name": "app", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "app", + "version": "0.0.0", + "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" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.148.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.148.0.tgz", + "integrity": "sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.81.0.tgz", + "integrity": "sha512-IcCRsXiedJoJopY6mpZUBEeVFsUrutmrG7dZ87zMuKJlhg70Ora9bBl1WcCxZQtyI10YpnVdEso5oCg7YcfSHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.81.0.tgz", + "integrity": "sha512-GRrIPyTGVhx3L3h+0T5xT2A0jFAcdPv4+IfuXpGDLIdl6XeYhgg/zw72A5ILZoUgRqZuM8F1y+V/gfDriXSxzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.81.0.tgz", + "integrity": "sha512-qNQ9tXRgLuKbqSV1S2h9h4KPHjbovO7RRR2/enUOtHzTkFZ7B9X5zqqHJua8dRyc7dBy7Aoyq5pqTSLFVcAzGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.81.0.tgz", + "integrity": "sha512-q0QTm32jWga2Gv4j7IaVZN0jYMi9UV73sWVgFtDA4iIfqwMCLLZ3ve+9KwfYtsaKZSgQhmPaogeZWqDZpcY1Pw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.81.0.tgz", + "integrity": "sha512-/+8wVWDXEC7wHVAhOc59Fw/SkMc1arLkFD8iQCaSsmzenK1X4doFqquL9H1wrtGUzaiycVqkf/sSpcILK6W1UA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.81.0.tgz", + "integrity": "sha512-4xt422FEgioRq9hAL4Tq7fujGUWnc8z1BJ+Oi8RN8vB8axaP+sdK6a2xdlcQCCYnJg9QMuMFS0AucuIFx/EacA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.81.0.tgz", + "integrity": "sha512-u3vna8KdGplH4DRCW9K54D68fcMo7IxVrkCJWwXnIhwtBdnDnYrmzOUA/XjmBlPpcLsgw9Z5BNdY4za9+Dj+MQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.81.0.tgz", + "integrity": "sha512-3j9k+gsYsE7nv71GWotXsqsa2l9/aJenD7dVHNt/CBvsb0SgRjSMnHFeP59IXUAl1wvVFhqGl2wJNMwWU3UBlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.81.0.tgz", + "integrity": "sha512-k5iAp3dNxW0/uDCBY+WSm8jKB2szu7SkEQZdgRRpDXvuDd69vvDcqhB3A/pWCfCwXyenjNjFn9Td1fVoyAc+Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.81.0.tgz", + "integrity": "sha512-TFqLja3uYmVSte6nof9GWrex9Z8WgdZrNiLC6Te5rXGDqXB2y4j/26iFhwosXiAFqDhE9JJVuuCkDKLwptTn1g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.81.0.tgz", + "integrity": "sha512-UEcySvGS0NOVo7h7n7CYyJL9+6gFAh7Zc/ToDXVScFvzHSTIxtzkMVU30rmQ6+nQ1LF+UdiRDdJajpDu+OylLg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.81.0.tgz", + "integrity": "sha512-H+diDbhD00+wI1IRP8Kz88x/lat+DgtoBJzoTthS16xkTJGNaEkfb8gzmd1rzc/2uDQQMl7GNl+JFUacVeWxIA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.81.0.tgz", + "integrity": "sha512-8znJ/5TekjOKg1j1Acho4PJMdiAHLtlcXuWEiipOhAMV6rQcXdmDdXCbheyDczN6TjBwiNfjcP81k4AthrKRzw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.81.0.tgz", + "integrity": "sha512-Q2Wj70yFsvn5QjlmifFzbj4H+kJy53bwqc41o1fzoM7MpLV1NIbhg/LpWXRfC6KOkSAdUx1Wd8VJsdPmhp/HRA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.81.0.tgz", + "integrity": "sha512-cPInHp/ddEe5qkyK2IiyQ8Q3Mp2oLLEhhsGgTK2oZx4L6+llGam1H1yBvJZ7qHfOXj8N3hxBS8sj4tO+gtFlIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.81.0.tgz", + "integrity": "sha512-0CQxSX4ajqm07AHBf5U33qQzXKdd7wtq/oTL/7vpY6RNNuxrRi8W4bqUV1Jyu/vj+9KmxQyDhxfeVX1nQL6kfg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.81.0.tgz", + "integrity": "sha512-l0hbeISm9673hVrrQU8j/p2M7YH9Ouoj7p7E/QM55NTrKVLP+P3PF8hLu+OY+x0VtGRW+ggiQKZqmdYps9H+TA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.81.0.tgz", + "integrity": "sha512-ksqPP5jbFXcYreEQ7zdJh06rJQBymCTyGRCdaXjfcf2aG4f8KxUWY5wcgYHmaTK+FJ4bPG5sUAdOX+6trnH1JA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.81.0.tgz", + "integrity": "sha512-IZuUCwGw9emG5JtCp+fYGB+Z4OWEoeEcM8R5BA1pYw63/ieYFVdcU2ylxTpHbVHSenZnsYE+ZZ20uHAJszQ4cA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.7.tgz", + "integrity": "sha512-EypzgnYCwyVY4NDHKzGmNJT5b+XaQEBniHxsMdeIQLB/tcCzZnhqrzHpZFbX9iaxx+5RiB8caATBtfvZP7zVxQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.7.tgz", + "integrity": "sha512-l17HE9EweWaqJZhuUuNBN/FzM62xw+DECVnJyvMsxn8vJFAGLy5QfLDoYAcronkAN8VxKZHezDpulHDPx95vFw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.7.tgz", + "integrity": "sha512-8ED8ELFvHXc6OCETIn4gXObPiaR6bckM/ipXtbzlPVDRMBfEGjCKgO90F9YtfdpDatVx/ZQw7aZ1vUMf/+T3Mw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.7.tgz", + "integrity": "sha512-/WPripjtiAIZ2tWY7ddijORT0Ujg87wxWW/qcoFVCKAWVDPhtY0xr7Dj0M3GyNGz60jGwTElhro/mkF9dT7dDQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.7.tgz", + "integrity": "sha512-14DI4NcqpvbICxSnGLx3PmtDaWqRP/KGSGb6C+JLLVPeZRl6dKdHba3pGsqT3vpdTqhEYIPG0MMQ8c0xYqoJxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.7.tgz", + "integrity": "sha512-bxrWIRvHWQvbJwi+VIie/kDJmQxcNE6xxWwZdqF/ExVAigtHkv54WTLQPb+QsZdnFy18fg7JPfWGL0RH6vwIlQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.7.tgz", + "integrity": "sha512-toOY2BChBZyuxU7OYX6Tn389di4IzAqPTycVcci0O7FSfBqzRB3RZn+K5Is6ANf4tmgRd/K1yZTsNTXbkXsnLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.7.tgz", + "integrity": "sha512-lAIXTH/aiLRLxsTgQvfhjo4K1ydWIp00+V0voOr9beb/9ZmkUFrSIb03dXNFRgMNvkE6oGsF10ioQ6UsI+vS5Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.7.tgz", + "integrity": "sha512-kdnwS28Pkenp/mZMRwjXXXwxQ7pIsm+bF919LUK93BOyhcLsrVKdP2p9fxpiPNPAbNuch8ypQt0pm2P2LYCAGg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.7.tgz", + "integrity": "sha512-516OdsyLdr5E65paF3yBF55t8mfm9+gmtCsK3xI7XKXIT7EfRlHhxL8K/NR6Hu8BWSgF5+1w74lTL0+nxcc8Qw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.7.tgz", + "integrity": "sha512-r8/z8n7GFaYRln3xmP1Cxy0HH/HLM0uBUPkEuSVEfKGDA89M0FsZRZJRSwe/tJjRx+fpH/gjorfhB8tmEbSFLA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.7.tgz", + "integrity": "sha512-pAsE8iiDxUg1xBqdhrTfg45AVDVpirjz00sblEYClGNNcMnDb+e8beQgqIAw6LvauX/APvgxUnwrgun/YYGBhw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.7.tgz", + "integrity": "sha512-lTcIYmmnQQA8Or/2DatS6oSqcdLHvendjS+zLu+FwgToynWMRSmQdpM65fTANJgIS4mjbMOo5KT2lnT9SAb96w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.7.tgz", + "integrity": "sha512-e3Gu3WxbNk/UqQhxqU7YIYO+9ZBvWNz3U+h/qRFosscMFzdRPbXYSaSWgSnklv2fz1TgzBTcti2z35c/7irsHw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.7.tgz", + "integrity": "sha512-W/jg5qoRSqjsEv0+dZi4e687mcHqmVuU0P4fK6qS/xjetW2Gmc1W8j//z5nAeNcC8Ttm0hV46IjcYeuVwYhuiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", + "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/oxlint": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.81.0.tgz", + "integrity": "sha512-HyrJYqeoOCL0iqaLEzGewGT48ZX99P3hxYh8udAF9RGGIghSamkXE4ClUyBpEDNqasamThgmlPbuMOe7SAZmHg==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.81.0", + "@oxlint/binding-android-arm64": "1.81.0", + "@oxlint/binding-darwin-arm64": "1.81.0", + "@oxlint/binding-darwin-x64": "1.81.0", + "@oxlint/binding-freebsd-x64": "1.81.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.81.0", + "@oxlint/binding-linux-arm-musleabihf": "1.81.0", + "@oxlint/binding-linux-arm64-gnu": "1.81.0", + "@oxlint/binding-linux-arm64-musl": "1.81.0", + "@oxlint/binding-linux-ppc64-gnu": "1.81.0", + "@oxlint/binding-linux-riscv64-gnu": "1.81.0", + "@oxlint/binding-linux-riscv64-musl": "1.81.0", + "@oxlint/binding-linux-s390x-gnu": "1.81.0", + "@oxlint/binding-linux-x64-gnu": "1.81.0", + "@oxlint/binding-linux-x64-musl": "1.81.0", + "@oxlint/binding-openharmony-arm64": "1.81.0", + "@oxlint/binding-win32-arm64-msvc": "1.81.0", + "@oxlint/binding-win32-ia32-msvc": "1.81.0", + "@oxlint/binding-win32-x64-msvc": "1.81.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2001", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.27", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.27.tgz", + "integrity": "sha512-79Iho8QeYyooJ8e9lCRyTVlyTAkS/kXBYKff6TMzS3kEWGQ8Ds5UEtXpGrSUDLUWok6QTvxeYy0GO8fopHnaSA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-router": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.3.tgz", + "integrity": "sha512-gyXgtdr5uACJ5b1Q4udzjVV+tb/rlHIMJKuJ0e89R4Kzgz47z/rgP0dIKxktqIEUhDHluGTPJJH/wRha7CyqsA==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.3.tgz", + "integrity": "sha512-ytVbyBBM7vMfRCam25r0WMhSVSom909A8p+8m0/f1w853dz/xfFu6etAT2SEbVoSnI+ZoPRDqIsQXVT89gp7kg==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.3" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/rolldown": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.7.tgz", + "integrity": "sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.148.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.7", + "@rolldown/binding-android-arm64": "1.2.7", + "@rolldown/binding-darwin-arm64": "1.2.7", + "@rolldown/binding-darwin-x64": "1.2.7", + "@rolldown/binding-freebsd-x64": "1.2.7", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.7", + "@rolldown/binding-linux-arm64-gnu": "1.2.7", + "@rolldown/binding-linux-arm64-musl": "1.2.7", + "@rolldown/binding-linux-ppc64-gnu": "1.2.7", + "@rolldown/binding-linux-s390x-gnu": "1.2.7", + "@rolldown/binding-linux-x64-gnu": "1.2.7", + "@rolldown/binding-linux-x64-musl": "1.2.7", + "@rolldown/binding-openharmony-arm64": "1.2.7", + "@rolldown/binding-win32-arm64-msvc": "1.2.7", + "@rolldown/binding-win32-x64-msvc": "1.2.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/vite": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..68d0035 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..18ecc92 --- /dev/null +++ b/frontend/src/App.css @@ -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; + } +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..0b3fc29 --- /dev/null +++ b/frontend/src/App.jsx @@ -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 ( +
+
+
+ + } /> + } /> + + + + } /> + + + + } + /> + + + + } /> + + + + } /> + } /> + +
+
+

© 2026 Le Fournil du Bocage. Tous droits réservés.

+
+
+ ); +} + +export default App; + diff --git a/frontend/src/api/axios.js b/frontend/src/api/axios.js new file mode 100644 index 0000000..24ef980 --- /dev/null +++ b/frontend/src/api/axios.js @@ -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; + diff --git a/frontend/src/api/time.js b/frontend/src/api/time.js new file mode 100644 index 0000000..bd892f9 --- /dev/null +++ b/frontend/src/api/time.js @@ -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); + } + }, +}; \ No newline at end of file diff --git a/frontend/src/api/users.js b/frontend/src/api/users.js new file mode 100644 index 0000000..610c579 --- /dev/null +++ b/frontend/src/api/users.js @@ -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); + } + }, +}; diff --git a/frontend/src/components/AttendanceApp.css b/frontend/src/components/AttendanceApp.css new file mode 100644 index 0000000..831aad8 --- /dev/null +++ b/frontend/src/components/AttendanceApp.css @@ -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; + } +} \ No newline at end of file diff --git a/frontend/src/components/AttendanceApp.jsx b/frontend/src/components/AttendanceApp.jsx new file mode 100644 index 0000000..ce466f8 --- /dev/null +++ b/frontend/src/components/AttendanceApp.jsx @@ -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 ; + default: + return ; + } + }; + + return ( +
+
+

Système de Pointage

+ +
+ +
+ {renderTabContent()} +
+
+ ); +}; + +export default AttendanceApp; \ No newline at end of file diff --git a/frontend/src/components/Header.css b/frontend/src/components/Header.css new file mode 100644 index 0000000..22029e9 --- /dev/null +++ b/frontend/src/components/Header.css @@ -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%; + } +} diff --git a/frontend/src/components/Header.jsx b/frontend/src/components/Header.jsx new file mode 100644 index 0000000..fe53b32 --- /dev/null +++ b/frontend/src/components/Header.jsx @@ -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 ( +
+
+ + + Le Fournil du Bocage + + +
+ + {/* Menu hamburger */} + {isMenuOpen && ( +
+ +
+ )} +
+ ); +}; + +export default Header; \ No newline at end of file diff --git a/frontend/src/components/ProtectedRoute.jsx b/frontend/src/components/ProtectedRoute.jsx new file mode 100644 index 0000000..7b88395 --- /dev/null +++ b/frontend/src/components/ProtectedRoute.jsx @@ -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
Loading...
; + } + + if (!user) { + // Redirect them to the /login page, but save the current location they were trying to go to. + return ; + } + + return children; +}; + +export default ProtectedRoute; diff --git a/frontend/src/components/RecapTab.css b/frontend/src/components/RecapTab.css new file mode 100644 index 0000000..9523ca9 --- /dev/null +++ b/frontend/src/components/RecapTab.css @@ -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; + } +} \ No newline at end of file diff --git a/frontend/src/components/RecapTab.jsx b/frontend/src/components/RecapTab.jsx new file mode 100644 index 0000000..7258d7f --- /dev/null +++ b/frontend/src/components/RecapTab.jsx @@ -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
Chargement...
; + } + + return ( +
+
+

Récapitulatif de {user.name}

+
+
+ Email: + {user.email} +
+
+ Département: + {user.department} +
+
+ Poste: + {user.position} +
+
+
+ +
+ +
+
+ ); +}; + +export default RecapTab; \ No newline at end of file diff --git a/frontend/src/components/TimeClockQR.css b/frontend/src/components/TimeClockQR.css new file mode 100644 index 0000000..50764ba --- /dev/null +++ b/frontend/src/components/TimeClockQR.css @@ -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; + } +} \ No newline at end of file diff --git a/frontend/src/components/TimeClockQR.jsx b/frontend/src/components/TimeClockQR.jsx new file mode 100644 index 0000000..0d23522 --- /dev/null +++ b/frontend/src/components/TimeClockQR.jsx @@ -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 ( +
+
+

Pointage par QR Code

+ +
+
+ {isScanning ? ( +
+
+

Scan en cours...

+
+ ) : scanResult ? ( +
+
+

Scan réussi !

+

Action: {scanResult.action}

+
+ ) : ( +
+
+

Positionnez le QR code dans le cadre

+
+ )} +
+
+ +
+

Sélectionnez votre action

+
+ + +
+
+ + + + {message && ( +
+ {message} +
+ )} + + {error && ( +
+ {error} +
+ )} +
+ + {/* Modal pour saisir l'heure d'entrée */} + {showEntryTimeModal && ( +
+
+

Heure d'entrée requise

+

Vous avez indiqué une sortie sans avoir pointé l'entrée.

+
+
+ + setEntryTime(e.target.value)} + required + /> +
+
+ + +
+
+
+
+ )} +
+ ); +}; + +export default TimeClockQR; \ No newline at end of file diff --git a/frontend/src/components/WeeklySchedule.css b/frontend/src/components/WeeklySchedule.css new file mode 100644 index 0000000..8007d55 --- /dev/null +++ b/frontend/src/components/WeeklySchedule.css @@ -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%; + } +} \ No newline at end of file diff --git a/frontend/src/components/WeeklySchedule.jsx b/frontend/src/components/WeeklySchedule.jsx new file mode 100644 index 0000000..5ae7948 --- /dev/null +++ b/frontend/src/components/WeeklySchedule.jsx @@ -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
Chargement...
; + } + + const weekDays = getWeekDays(currentWeek); + const weekDates = weekDays.map(day => formatDate(day)); + + return ( +
+
+

Emploi du temps hebdomadaire

+
+ + + 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' })} + + +
+
+ +
+
+
Jour
+
Heures de présence
+
+ + {weekDays.map((day, index) => ( +
+
{weekDates[index]}
+
{getHoursForDay(day)}
+
+ ))} +
+
+ ); +}; + +export default WeeklySchedule; \ No newline at end of file diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx new file mode 100644 index 0000000..20858a0 --- /dev/null +++ b/frontend/src/context/AuthContext.jsx @@ -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 ( + + {children} + + ); +}; + +export const useAuth = () => useContext(AuthContext); diff --git a/frontend/src/main.css b/frontend/src/main.css new file mode 100644 index 0000000..b504f26 --- /dev/null +++ b/frontend/src/main.css @@ -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; +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..723749a --- /dev/null +++ b/frontend/src/main.jsx @@ -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( + + + + + + + , +) diff --git a/frontend/src/pages/AdminInvitePage.jsx b/frontend/src/pages/AdminInvitePage.jsx new file mode 100644 index 0000000..e028054 --- /dev/null +++ b/frontend/src/pages/AdminInvitePage.jsx @@ -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 ( +
+

Générer un lien d'invitation

+ + {error &&
{error}
} + +
+ +
+ + {inviteLink && ( +
+

Lien d'invitation généré :

+
+ + +
+

+ Partagez ce lien avec les nouveaux utilisateurs pour qu'ils puissent créer un compte. +

+
+ )} + +
+

Comment cela fonctionne-t-il ?

+
    +
  • Les utilisateurs doivent utiliser un lien d'invitation pour créer un compte
  • +
  • Cela permet de contrôler qui peut accéder à l'application
  • +
  • Les nouveaux comptes sont créés avec des permissions standard
  • +
  • Vous pouvez promouvoir des utilisateurs en administrateurs depuis la page de gestion
  • +
+
+
+ ); +}; + +export default AdminInvitePage; \ No newline at end of file diff --git a/frontend/src/pages/AdminUsersPage.jsx b/frontend/src/pages/AdminUsersPage.jsx new file mode 100644 index 0000000..f42f8a5 --- /dev/null +++ b/frontend/src/pages/AdminUsersPage.jsx @@ -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 ( +
+

Gestion des comptes

+

Chargement des utilisateurs...

+
+ ); + } + + return ( +
+

Gestion des comptes

+ {error &&
{error}
} + +
+ + +
+ +
+ + + + + + + + + + + + {users.map((user) => ( + + + + + + + + ))} + +
Nom d'utilisateurEmailRôleInscrit leActions
{user.username}{user.email} + {user.is_admin ? ( + Administrateur + ) : ( + Utilisateur + )} + {new Date(user.date_joined).toLocaleDateString('fr-FR')} +
+ {!user.is_admin && ( + + )} + +
+
+
+
+ ); +}; + +export default AdminUsersPage; diff --git a/frontend/src/pages/HomePage.css b/frontend/src/pages/HomePage.css new file mode 100644 index 0000000..6a85383 --- /dev/null +++ b/frontend/src/pages/HomePage.css @@ -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; + } +} diff --git a/frontend/src/pages/HomePage.jsx b/frontend/src/pages/HomePage.jsx new file mode 100644 index 0000000..093b977 --- /dev/null +++ b/frontend/src/pages/HomePage.jsx @@ -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 ( +
+
+
+ ); + } + + return ( +
+
+

Profil Utilisateur

+ {userData && ( +
+
+ Nom d'utilisateur: + {userData.username} +
+
+ Email: + {userData.email || 'Non spécifié'} +
+
+ Admin: + {userData.is_admin ? 'Oui' : 'Non'} +
+
+ Date d'inscription: + {userData.date_joined || 'Non spécifié'} +
+
+ )} +
+
+ ); +}; + +export default HomePage; \ No newline at end of file diff --git a/frontend/src/pages/LoginPage.css b/frontend/src/pages/LoginPage.css new file mode 100644 index 0000000..2658f2b --- /dev/null +++ b/frontend/src/pages/LoginPage.css @@ -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; +} diff --git a/frontend/src/pages/LoginPage.jsx b/frontend/src/pages/LoginPage.jsx new file mode 100644 index 0000000..ae7f6b0 --- /dev/null +++ b/frontend/src/pages/LoginPage.jsx @@ -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 ( +
+
+

Connexion

+ {error &&
{error}
} +
+
+ + setUsername(e.target.value)} + className="form-control" + required + /> +
+
+ + setPassword(e.target.value)} + className="form-control" + required + /> +
+ +
+
+
+ ); +}; + +export default LoginPage; diff --git a/frontend/src/pages/RegisterPage.jsx b/frontend/src/pages/RegisterPage.jsx new file mode 100644 index 0000000..93d13a7 --- /dev/null +++ b/frontend/src/pages/RegisterPage.jsx @@ -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

Lien invalide

Un lien d’invitation est nécessaire pour créer un compte.

; + } + + if (isCreated) { + return

Compte créé

Votre compte est prêt. Vous pouvez maintenant vous connecter.

; + } + + return ( +
+
+

Créer un compte

+ {error &&
{error}
} +
+
setForm({ ...form, username: event.target.value })} />
+
setForm({ ...form, email: event.target.value })} />
+
setForm({ ...form, password: event.target.value })} />
+ +
+
+
+ ); +}; + +export default RegisterPage; diff --git a/frontend/src/pages/TimeClockPage.css b/frontend/src/pages/TimeClockPage.css new file mode 100644 index 0000000..858a879 --- /dev/null +++ b/frontend/src/pages/TimeClockPage.css @@ -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; +} \ No newline at end of file diff --git a/frontend/src/pages/TimeClockPage.jsx b/frontend/src/pages/TimeClockPage.jsx new file mode 100644 index 0000000..9b30f27 --- /dev/null +++ b/frontend/src/pages/TimeClockPage.jsx @@ -0,0 +1,328 @@ +import { useEffect, useRef, useState } from 'react'; +import './TimeClockPage.css'; +import { timeApi } from '../api/time'; + +const TimeClockPage = () => { + const videoRef = useRef(null); + const streamRef = useRef(null); + const scanFrameRef = useRef(null); + const [isScanning, setIsScanning] = useState(false); + const [scanResult, setScanResult] = useState(''); + const [error, setError] = useState(''); + const [currentTime, setCurrentTime] = useState(new Date()); + const [timeEntries, setTimeEntries] = useState([]); + const [loading, setLoading] = useState(true); + const [newEntry, setNewEntry] = useState({ + date: new Date().toISOString().split('T')[0], + start_time: '', + end_time: '' + }); + const [editingEntry, setEditingEntry] = useState(null); + const [editForm, setEditForm] = useState({ + start_time: '', + end_time: '' + }); + + // Update current time every second + useEffect(() => { + const timer = setInterval(() => { + setCurrentTime(new Date()); + }, 1000); + + return () => clearInterval(timer); + }, []); + + // Load time entries when component mounts + useEffect(() => { + loadTimeEntries(); + }, []); + + const loadTimeEntries = async () => { + try { + setLoading(true); + const entries = await timeApi.getTimeEntries(); + setTimeEntries(entries); + setError(''); + } catch (err) { + setError('Erreur lors du chargement des pointages'); + console.error(err); + } finally { + setLoading(false); + } + }; + + const handleAddEntry = async (e) => { + e.preventDefault(); + if (!newEntry.start_time || !newEntry.end_time) { + setError('Veuillez remplir tous les champs'); + return; + } + + try { + await timeApi.createTimeEntry(newEntry); + setNewEntry({ + date: new Date().toISOString().split('T')[0], + start_time: '', + end_time: '' + }); + loadTimeEntries(); // Refresh the list + setError(''); + } catch (err) { + setError('Erreur lors de l\'ajout du pointage'); + console.error(err); + } + }; + + const handleEditEntry = (entry) => { + setEditingEntry(entry); + setEditForm({ + start_time: entry.start_time, + end_time: entry.end_time + }); + }; + + const handleUpdateEntry = async (e) => { + e.preventDefault(); + if (!editForm.start_time || !editForm.end_time) { + setError('Veuillez remplir tous les champs'); + return; + } + + try { + await timeApi.updateTimeEntry(editingEntry.id, editForm); + setEditingEntry(null); + setEditForm({ start_time: '', end_time: '' }); + loadTimeEntries(); // Refresh the list + setError(''); + } catch (err) { + setError('Erreur lors de la mise à jour du pointage'); + console.error(err); + } + }; + + const handleDeleteEntry = async (id) => { + if (!window.confirm('Êtes-vous sûr de vouloir supprimer ce pointage ?')) { + return; + } + + try { + await timeApi.deleteTimeEntry(id); + loadTimeEntries(); // Refresh the list + setError(''); + } catch (err) { + setError('Erreur lors de la suppression du pointage'); + console.error(err); + } + }; + + const stopScanner = () => { + if (scanFrameRef.current) { + cancelAnimationFrame(scanFrameRef.current); + scanFrameRef.current = null; + } + streamRef.current?.getTracks().forEach((track) => track.stop()); + streamRef.current = null; + if (videoRef.current) { + videoRef.current.srcObject = null; + } + setIsScanning(false); + }; + + useEffect(() => () => stopScanner(), []); + + const startScanner = async () => { + setError(''); + setScanResult(''); + + if (!('BarcodeDetector' in window)) { + setError('La lecture QR n’est pas prise en charge par ce navigateur. Utilisez Chrome ou Edge à jour.'); + return; + } + + try { + const supportedFormats = await window.BarcodeDetector.getSupportedFormats(); + if (!supportedFormats.includes('qr_code')) { + setError('La lecture des QR codes n’est pas disponible sur cet appareil.'); + return; + } + + const stream = await navigator.mediaDevices.getUserMedia({ + video: { facingMode: { ideal: 'environment' } }, + audio: false, + }); + streamRef.current = stream; + videoRef.current.srcObject = stream; + await videoRef.current.play(); + setIsScanning(true); + + const detector = new window.BarcodeDetector({ formats: ['qr_code'] }); + const scan = async () => { + if (!videoRef.current || videoRef.current.readyState < 2) { + scanFrameRef.current = requestAnimationFrame(scan); + return; + } + + const codes = await detector.detect(videoRef.current); + if (codes.length > 0) { + setScanResult(codes[0].rawValue); + stopScanner(); + return; + } + scanFrameRef.current = requestAnimationFrame(scan); + }; + scanFrameRef.current = requestAnimationFrame(scan); + } catch (scannerError) { + setError('Impossible d’accéder à la caméra. Vérifiez les permissions de votre navigateur.'); + console.error(scannerError); + stopScanner(); + } + }; + + const formatTime = (timeString) => { + if (!timeString) return ''; + const [hours, minutes] = timeString.split(':'); + return `${hours}h${minutes}`; + }; + + const formatDate = (dateString) => { + const date = new Date(dateString); + return date.toLocaleDateString('fr-FR'); + }; + + const calculateTotalHours = () => { + return timeEntries.reduce((total, entry) => total + (entry.duration || 0), 0); + }; + + return ( +
+
+ Présence +

Pointage

+

Scannez le QR code présent sur votre lieu de travail pour enregistrer votre passage.

+
+ + {error &&
{error}
} + + {isScanning ? ( +
+
+ ); +}; + +export default TimeClockPage; diff --git a/frontend/src/pages/WeeklySummaryPage.jsx b/frontend/src/pages/WeeklySummaryPage.jsx new file mode 100644 index 0000000..e031dff --- /dev/null +++ b/frontend/src/pages/WeeklySummaryPage.jsx @@ -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 ( +
+

Récapitulatif hebdomadaire

+

Chargement...

+
+ ); + } + + if (error) { + return ( +
+

Récapitulatif hebdomadaire

+
{error}
+
+ ); + } + + if (!weeklySummary) { + return ( +
+

Récapitulatif hebdomadaire

+

Aucune donnée disponible

+
+ ); + } + + return ( +
+

Récapitulatif hebdomadaire

+ +
+

{weeklySummary.username}

+

Total des heures cette semaine: {weeklySummary.total_hours}h

+
+ +
+

Emploi du temps hebdomadaire

+
+ {weeklySummary.days.map((day, index) => ( +
+
+ {getDayName(index)} + {new Date(day.date).toLocaleDateString('fr-FR')} +
+
+ {day.start_time ? ( + <> +
+ Entrée: + {formatTime(day.start_time)} +
+
+ Sortie: + {formatTime(day.end_time)} +
+
+ Durée: + {day.duration.toFixed(2)}h +
+ + ) : ( +
+ Aucun pointage +
+ )} +
+
+ ))} +
+
+ +
+ +
+
+ ); +}; + +export default WeeklySummaryPage; \ No newline at end of file diff --git a/frontend/src/services/attendanceStorage.js b/frontend/src/services/attendanceStorage.js new file mode 100644 index 0000000..f82c279 --- /dev/null +++ b/frontend/src/services/attendanceStorage.js @@ -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(); \ No newline at end of file diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..0fb3c7f --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + + preview: { + allowedHosts: [ + 'boulangerie.louismazin.ovh' + ] + } +}) \ No newline at end of file diff --git a/start.sh b/start.sh new file mode 100644 index 0000000..2d5ff42 --- /dev/null +++ b/start.sh @@ -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" \ No newline at end of file