diff --git a/backend/core/settings.py b/backend/core/settings.py index 9e95823..b9ba70b 100644 --- a/backend/core/settings.py +++ b/backend/core/settings.py @@ -45,6 +45,7 @@ INSTALLED_APPS = [ # Local apps 'users', 'time_tracking', + 'events', ] MIDDLEWARE = [ @@ -155,3 +156,4 @@ SIMPLE_JWT = { # Default primary key field type # https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field + diff --git a/backend/core/urls.py b/backend/core/urls.py index 30c45ef..9686b2d 100644 --- a/backend/core/urls.py +++ b/backend/core/urls.py @@ -27,4 +27,5 @@ urlpatterns = [ path('api/auth/refresh/', TokenRefreshView.as_view(), name='token_refresh'), path('api/users/', include('users.urls')), path('api/time/', include('time_tracking.urls')), + path('api/event/', include('events.urls')), ] diff --git a/backend/events/migrations/0001_initial.py b/backend/events/migrations/0001_initial.py new file mode 100644 index 0000000..951bbe3 --- /dev/null +++ b/backend/events/migrations/0001_initial.py @@ -0,0 +1,33 @@ +# Generated by Django 5.2.17 on 2026-09-21 15:23 + +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='Event', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('start_date', models.DateField()), + ('end_date', models.DateField()), + ('start_time', models.TimeField()), + ('end_time', models.TimeField()), + ('name', models.CharField(max_length=255)), + ('description', models.TextField(blank=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('linked_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='linked_events', to=settings.AUTH_USER_MODEL)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='created_events', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/backend/events/migrations/__init__.py b/backend/events/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/events/models.py b/backend/events/models.py new file mode 100644 index 0000000..ce5fcb7 --- /dev/null +++ b/backend/events/models.py @@ -0,0 +1,30 @@ +from django.db import models +from users.models import User + + +class Event(models.Model): + id = models.AutoField(primary_key=True) + user = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name='created_events', + ) + linked_user = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name='linked_events', + null=True, + blank=True, + ) + start_date = models.DateField() + end_date = models.DateField() + start_time = models.TimeField() + end_time = models.TimeField() + name = models.CharField(max_length=255) + description = models.TextField(blank=True) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return self.name \ No newline at end of file diff --git a/backend/events/serializers.py b/backend/events/serializers.py new file mode 100644 index 0000000..5a0943e --- /dev/null +++ b/backend/events/serializers.py @@ -0,0 +1,54 @@ +from rest_framework import serializers +from .models import Event +from users.serializers import UserSerializer + + +class EventSerializer(serializers.ModelSerializer): + user = UserSerializer(read_only=True) + linked_user = UserSerializer(read_only=True) + + class Meta: + model = Event + fields = [ + 'id', + 'user', + 'linked_user', + 'start_date', + 'end_date', + 'start_time', + 'end_time', + 'name', + 'description', + 'created_at', + 'updated_at' + ] + read_only_fields = ['id', 'user', 'created_at', 'updated_at'] + + +class EventCreateSerializer(serializers.ModelSerializer): + class Meta: + model = Event + fields = [ + 'user', + 'linked_user', + 'start_date', + 'end_date', + 'start_time', + 'end_time', + 'name', + 'description' + ] + + +class EventUpdateSerializer(serializers.ModelSerializer): + class Meta: + model = Event + fields = [ + 'linked_user', + 'start_date', + 'end_date', + 'start_time', + 'end_time', + 'name', + 'description' + ] diff --git a/backend/events/urls.py b/backend/events/urls.py new file mode 100644 index 0000000..991e4cd --- /dev/null +++ b/backend/events/urls.py @@ -0,0 +1,8 @@ +from django.urls import path +from . import views + +urlpatterns = [ + path('', views.EventListView.as_view(), name='event-list'), + path('/', views.EventDetailView.as_view(), name='event-detail'), + path('create/', views.EventCreateView.as_view(), name='event-create'), +] \ No newline at end of file diff --git a/backend/events/views.py b/backend/events/views.py new file mode 100644 index 0000000..2e3d8bc --- /dev/null +++ b/backend/events/views.py @@ -0,0 +1,57 @@ +from rest_framework import generics, status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from .models import Event +from .serializers import EventSerializer, EventCreateSerializer, EventUpdateSerializer + + +class EventListView(generics.ListCreateAPIView): + serializer_class = EventSerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + user = self.request.user + return Event.objects.filter(user=user).select_related('user', 'linked_user') + + def perform_create(self, serializer): + serializer.save(user=self.request.user) + + +class EventDetailView(generics.RetrieveUpdateDestroyAPIView): + serializer_class = EventSerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + user = self.request.user + return Event.objects.filter(user=user).select_related('user', 'linked_user') + + def update(self, request, *args, **kwargs): + partial = kwargs.pop('partial', False) + instance = self.get_object() + + # Use the update serializer for updates + serializer = EventUpdateSerializer( + instance, + data=request.data, + partial=partial + ) + serializer.is_valid(raise_exception=True) + self.perform_update(serializer) + + return Response(serializer.data) + + def destroy(self, request, *args, **kwargs): + instance = self.get_object() + self.perform_destroy(instance) + return Response( + {'message': 'Event deleted successfully'}, + status=status.HTTP_200_OK + ) + + +class EventCreateView(generics.CreateAPIView): + serializer_class = EventCreateSerializer + permission_classes = [IsAuthenticated] + + def perform_create(self, serializer): + serializer.save(user=self.request.user) \ No newline at end of file diff --git a/backend/time_tracking/serializers.py b/backend/time_tracking/serializers.py index af03846..d36e2de 100644 --- a/backend/time_tracking/serializers.py +++ b/backend/time_tracking/serializers.py @@ -4,24 +4,13 @@ from uuid import uuid4 from django.contrib.auth import get_user_model from django.db import transaction from rest_framework import serializers - +from users.serializers import UserSerializer from .models import TimeEntry User = get_user_model() -class UserSerializer(serializers.ModelSerializer): - class Meta: - model = User - fields = [ - 'id', - 'username', - 'first_name', - 'last_name', - ] - - class TimeEntrySerializer(serializers.ModelSerializer): user = UserSerializer(read_only=True) diff --git a/backend/users/apps.py b/backend/users/apps.py index bcfe0ea..e7abb63 100644 --- a/backend/users/apps.py +++ b/backend/users/apps.py @@ -69,7 +69,7 @@ class UsersConfig(AppConfig): except Exception as e: # Don't fail the entire Django startup if there's an error - logger.error(f'Error creating admin user: {e}') + print(f'Error creating admin user: {e}') # Start the superuser creation in a separate thread to avoid the warning thread = threading.Thread(target=create_superuser_if_needed, daemon=True) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 7f6f806..f69eca8 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -20,6 +20,7 @@ import AdminUsersPage from './pages/AdminUsersPage'; import RegisterPage from './pages/RegisterPage'; import TimeClockPage from './pages/TimeClockPage'; import WeeklySummaryPage from './pages/WeeklySummaryPage'; +import CalendarPage from './pages/CalendarPage'; import { useAuth } from './context/useAuth'; // Composant personnalisé pour le trigger mobile avec icône menu @@ -108,6 +109,15 @@ function App() { } /> + + + + } + /> + } diff --git a/frontend/src/api/event.js b/frontend/src/api/event.js new file mode 100644 index 0000000..0882840 --- /dev/null +++ b/frontend/src/api/event.js @@ -0,0 +1,58 @@ +import axios from './axios'; + +export const eventApi = { + // Récupérer tous les événements + getAllEvents: async () => { + try { + const response = await axios.get('/event/'); + return response.data; + } catch (error) { + console.error('Error fetching events:', error); + throw error; + } + }, + + // Créer un nouvel événement + createEvent: async (eventData) => { + try { + const response = await axios.post('/event/create/', eventData); + return response.data; + } catch (error) { + console.error('Error creating event:', error); + throw error; + } + }, + + // Mettre à jour un événement + updateEvent: async (eventId, eventData) => { + try { + const response = await axios.put(`/event/${eventId}/`, eventData); + return response.data; + } catch (error) { + console.error('Error updating event:', error); + throw error; + } + }, + + // Supprimer un événement + deleteEvent: async (eventId) => { + try { + const response = await axios.delete(`/event/${eventId}/`); + return response.data; + } catch (error) { + console.error('Error deleting event:', error); + throw error; + } + }, + + // Récupérer un événement spécifique + getEvent: async (eventId) => { + try { + const response = await axios.get(`/event/${eventId}/`); + return response.data; + } catch (error) { + console.error('Error fetching event:', error); + throw error; + } + } +}; \ No newline at end of file diff --git a/frontend/src/components/LoadingCard.jsx b/frontend/src/components/LoadingCard.jsx new file mode 100644 index 0000000..d042fd3 --- /dev/null +++ b/frontend/src/components/LoadingCard.jsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from '@/components/ui/card'; + +const LoadingCard = ({ + icon: Icon, + title, + message = 'Chargement...', +}) => { + return ( + + + + {Icon && } + {title} + + + + +
+
+ + + {message} + +
+ + + ); +}; + +export default LoadingCard; \ No newline at end of file diff --git a/frontend/src/components/MainCard.jsx b/frontend/src/components/MainCard.jsx new file mode 100644 index 0000000..0e95828 --- /dev/null +++ b/frontend/src/components/MainCard.jsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from '@/components/ui/card'; + +const MainCard = ({ + title, + children, + icon: Icon, + headerContent, + className = '', + contentClassName = '', +}) => { + return ( + + + + {Icon && } + {title} + + + {headerContent && ( +
+ {headerContent} +
+ )} +
+ + {children} +
+ ); +}; + +export default MainCard; \ No newline at end of file diff --git a/frontend/src/components/NavBar.jsx b/frontend/src/components/NavBar.jsx index 29846a9..69d462b 100644 --- a/frontend/src/components/NavBar.jsx +++ b/frontend/src/components/NavBar.jsx @@ -22,6 +22,7 @@ import { Users, LogOut, Wheat, + Calendar, } from 'lucide-react'; const NavBar = () => { @@ -74,27 +75,17 @@ const NavBar = () => { icon: User, }, { - title: 'Résumé hebdomadaire', + title: 'Planning', href: '/weekly-summary', - icon: Clock, + icon: CalendarDays, }, { title: 'Pointage', href: '/pointage', - icon: CalendarDays, + icon: Clock, }, ]; - /* - * Hauteur d'une ligne de navigation. - * - * Le SidebarMenuButton natif fait 32px. - * On lui donne une ligne de 48px et on le centre. - * - * Le header de groupe fait lui aussi 32px. - * En mode plié, il est remplacé visuellement par - * une séparation, mais conserve exactement ses 32px. - */ const menuItemClass = 'h-12 min-h-12 flex items-center'; const buttonClass = ` @@ -114,17 +105,6 @@ const NavBar = () => { text-base `; - /* - * Header de groupe : - * - * Déplié : - * [ Navigation ] 32px - * - * Plié : - * [ ---------------------- ] 32px - * - * Même hauteur dans les deux cas. - */ const GroupHeader = ({ children }) => (
+ + Entreprise + + + + + + + + + + Calendrier + + + } + /> + + + + + {/* Administration */} {user.is_admin && ( @@ -269,7 +280,7 @@ const NavBar = () => { - Comptes existants + Utilisateurs } diff --git a/frontend/src/components/ui/card.jsx b/frontend/src/components/ui/card.jsx index 33ea7ec..138413f 100644 --- a/frontend/src/components/ui/card.jsx +++ b/frontend/src/components/ui/card.jsx @@ -11,7 +11,7 @@ function Card({ data-slot="card" data-size={size} className={cn( - "group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl", + "group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl border border-border bg-card py-(--card-spacing) text-sm text-card-foreground [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl", className )} {...props} diff --git a/frontend/src/pages/AdminUsersPage.jsx b/frontend/src/pages/AdminUsersPage.jsx index 9ea46c5..a900e52 100644 --- a/frontend/src/pages/AdminUsersPage.jsx +++ b/frontend/src/pages/AdminUsersPage.jsx @@ -1,5 +1,7 @@ import React, { useState, useEffect } from 'react'; import { userApi } from '../api/users'; +import LoadingCard from '@/components/LoadingCard'; +import MainCard from '@/components/MainCard'; import { Button } from '@/components/ui/button'; import { Table, @@ -10,7 +12,7 @@ import { TableRow } from '@/components/ui/table'; import { Input } from '@/components/ui/input'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { CardContent } from '@/components/ui/card'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { Badge } from '@/components/ui/badge'; import { Checkbox } from '@/components/ui/checkbox'; @@ -18,7 +20,7 @@ import ConfirmDialog from '@/components/ConfirmDialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; import { Label } from '@/components/ui/label'; import { useAuth } from '../context/useAuth'; - +import { Users} from 'lucide-react'; const AdminUsersPage = () => { const { user, logout } = useAuth(); const [users, setUsers] = useState([]); @@ -147,213 +149,288 @@ const AdminUsersPage = () => { if (loading) { return ( - - - Gestion des comptes - - -

Chargement des utilisateurs...

-
-
+ ); } return ( - - - Gestion des comptes - - + {error && ( {error} )} + +
+ -
- - - - Nom - Couleur - Rôle - Inscrit le - Actions - - - - {users.map((user) => ( - - {user.first_name} {user.last_name} - -
-
- {user.color || 'Bleu'} -
-
- - - {user.is_admin ? 'Administrateur' : 'Utilisateur'} - - - {new Date(user.date_joined).toLocaleDateString('fr-FR')} - -
- { - if (!open) { - setEditingUser(null); - } else { - setEditingUser(user.id); - setEditForm({ - first_name: user.first_name || '', - last_name: user.last_name || '', - email: user.email || '', - color: user.color || '#0000ff', - is_active: user.is_active, - is_staff: user.is_staff || user.is_admin, - is_superuser: user.is_superuser, - }); - } - }}> - - - - - - Modifier l'utilisateur - -
-
-
- - setEditForm({ ...editForm, first_name: event.target.value })} - /> -
-
- - setEditForm({ ...editForm, last_name: event.target.value })} - /> -
-
-
- - setEditForm({ ...editForm, email: event.target.value })} - /> -
-
- -
- setEditForm({ ...editForm, color: event.target.value })} - className="h-10 w-14 cursor-pointer p-1" - /> - setEditForm({ ...editForm, color: event.target.value })} - className="max-w-36 font-mono uppercase" - /> -
-
- - -

Le mot de passe ne peut pas être modifié ici.

-
- -
-
- {!user.is_admin && ( - handleMakeAdmin(user.id)} - > - - - )} - handleDeleteUser(user.id)} - > - - -
-
-
- ))} -
-
-
- -
- {inviteLink ? ( -
- -
+ {inviteLink && ( + <> +
- -
-
- ) : ( - +
+ + + )}
+ +
+ + + + Nom + Couleur + Rôle + Inscrit le + Actions + + + + + {users.map((user) => ( + + + {user.first_name} {user.last_name} + + + +
+
+ {user.color || 'Bleu'} +
+ + + + + {user.is_admin ? 'Administrateur' : 'Utilisateur'} + + + + + {new Date(user.date_joined).toLocaleDateString('fr-FR')} + + + +
+ { + if (!open) { + setEditingUser(null); + } else { + setEditingUser(user.id); + setEditForm({ + first_name: user.first_name || '', + last_name: user.last_name || '', + email: user.email || '', + color: user.color || '#0000ff', + is_active: user.is_active, + is_staff: user.is_staff || user.is_admin, + is_superuser: user.is_superuser, + }); + } + }} + > + + + + + + + Modifier l'utilisateur + + +
+
+
+ + + setEditForm({ + ...editForm, + first_name: event.target.value, + }) + } + /> +
+ +
+ + + setEditForm({ + ...editForm, + last_name: event.target.value, + }) + } + /> +
+
+ +
+ + + setEditForm({ + ...editForm, + email: event.target.value, + }) + } + /> +
+ +
+ + +
+ + setEditForm({ + ...editForm, + color: event.target.value, + }) + } + className="h-10 w-14 cursor-pointer p-1" + /> + + + setEditForm({ + ...editForm, + color: event.target.value, + }) + } + className="max-w-36 font-mono uppercase" + /> +
+
+ + + + + +

+ Le mot de passe ne peut pas être modifié ici. +

+
+ + +
+
+ + {!user.is_admin && ( + handleMakeAdmin(user.id)} + > + + + )} + + handleDeleteUser(user.id)} + > + + +
+
+ + ))} + +
+
- + ); }; diff --git a/frontend/src/pages/CalendarPage.jsx b/frontend/src/pages/CalendarPage.jsx new file mode 100644 index 0000000..0668b15 --- /dev/null +++ b/frontend/src/pages/CalendarPage.jsx @@ -0,0 +1,366 @@ +import React, { + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react'; +import LoadingCard from '@/components/LoadingCard'; +import MainCard from '@/components/MainCard'; +import { + Card, + CardContent +} from '@/components/ui/card'; + +import { Button } from '@/components/ui/button'; + +import { + ChevronLeft, + ChevronRight, + Calendar, +} from 'lucide-react'; + +const MONTHS = [ + 'Janvier', + 'Février', + 'Mars', + 'Avril', + 'Mai', + 'Juin', + 'Juillet', + 'Août', + 'Septembre', + 'Octobre', + 'Novembre', + 'Décembre', +]; + +const DAYS_IN_MONTH = [ + 31, + 28, + 31, + 30, + 31, + 30, + 31, + 31, + 30, + 31, + 30, + 31, +]; + +const HEADER_HEIGHT = 52; +const DAY_COLUMN_WIDTH = 52; +const MIN_MONTH_WIDTH = 90; +const MIN_ROW_HEIGHT = 28; + +const CalendarPage = () => { + const [year, setYear] = useState(new Date().getFullYear()); + const [isMobile, setIsMobile] = useState(false); + const [availableHeight, setAvailableHeight] = useState(600); + const calendarRef = useRef(null); + + const isLeapYear = (value) => { + return ( + (value % 4 === 0 && value % 100 !== 0) || + value % 400 === 0 + ); + }; + + const getDaysInMonth = (monthIndex) => { + if (monthIndex === 1 && isLeapYear(year)) { + return 29; + } + + return DAYS_IN_MONTH[monthIndex]; + }; + + useEffect(() => { + const updateMobile = () => { + setIsMobile(window.innerWidth < 768); + }; + + updateMobile(); + + window.addEventListener('resize', updateMobile); + + return () => { + window.removeEventListener('resize', updateMobile); + }; + }, []); + + useLayoutEffect(() => { + const updateDimensions = () => { + const calendar = calendarRef.current; + + if (!calendar) return; + + const cardContent = calendar.closest( + '[data-slot="card-content"]', + ); + + if (!cardContent) return; + + const calendarTop = calendar.getBoundingClientRect().top; + const contentRect = cardContent.getBoundingClientRect(); + + const footer = document.querySelector('footer'); + + const footerTop = footer + ? footer.getBoundingClientRect().top + : window.innerHeight; + + const bottom = Math.min( + contentRect.bottom, + footerTop, + window.innerHeight, + ); + + const height = bottom - calendarTop - 4; + + setAvailableHeight(Math.max(300, height)); + }; + + updateDimensions(); + + window.addEventListener('resize', updateDimensions); + + let observer = null; + + if ( + typeof ResizeObserver !== 'undefined' && + calendarRef.current + ) { + observer = new ResizeObserver(updateDimensions); + observer.observe(calendarRef.current); + } + + return () => { + window.removeEventListener('resize', updateDimensions); + observer?.disconnect(); + }; + }, [year]); + + const previousYear = () => { + setYear((current) => current - 1); + }; + + const nextYear = () => { + setYear((current) => current + 1); + }; + + const currentYear = () => { + setYear(new Date().getFullYear()); + }; + + const today = new Date(); + + const isToday = (monthIndex, day) => { + return ( + today.getFullYear() === year && + today.getMonth() === monthIndex && + today.getDate() === day + ); + }; + + const rowCount = 31; + + const availableGridHeight = Math.max( + availableHeight - HEADER_HEIGHT, + rowCount * MIN_ROW_HEIGHT, + ); + + const rowHeight = Math.max( + MIN_ROW_HEIGHT, + availableGridHeight / rowCount, + ); + + const minimumGridWidth = + DAY_COLUMN_WIDTH + MONTHS.length * MIN_MONTH_WIDTH; + + return ( + + + + + + + }> + + + +
+
+
+ Jour +
+ + {MONTHS.map((month) => ( +
+ {month} +
+ ))} + + {Array.from({ length: 31 }, (_, dayIndex) => { + const day = dayIndex + 1; + + return ( + +
+ {day} +
+ + {MONTHS.map((month, monthIndex) => { + const valid = + day <= getDaysInMonth(monthIndex); + + const todayCell = + valid && isToday(monthIndex, day); + + if (!valid) { + return ( +
+ ); + } + + return ( +
+ {todayCell && ( +
+ {day} +
+ )} +
+ ); + })} + + ); + })} +
+
+ + + + ); +}; + +export default CalendarPage; \ No newline at end of file diff --git a/frontend/src/pages/HomePage.jsx b/frontend/src/pages/HomePage.jsx index 25f56b0..1279d41 100644 --- a/frontend/src/pages/HomePage.jsx +++ b/frontend/src/pages/HomePage.jsx @@ -1,65 +1,73 @@ import React from 'react'; +import LoadingCard from '@/components/LoadingCard'; +import MainCard from '@/components/MainCard'; import { useAuth } from '../context/useAuth'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { + CardContent, +} from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Separator } from '@/components/ui/separator'; +import { User } from 'lucide-react'; const HomePage = () => { const { user } = useAuth(); + if (!user) { + return ( + + ); + } + return ( -
- - - Profil Utilisateur - + - - {user && ( -
-
- Prénom: - {user.first_name} -
+ +
+
+ Prénom: + {user.first_name} +
- + -
- Nom: - {user.last_name} -
+
+ Nom: + {user.last_name} +
- + -
- Admin: - - {user.is_admin ? 'Oui' : 'Non'} - -
+
+ Admin: + + {user.is_admin ? 'Oui' : 'Non'} + +
- + -
- Nom d'utilisateur: - {user.username} -
+
+ Nom d'utilisateur: + {user.username} +
- + -
- Date d'inscription: - - {user.date_joined - ? new Date(user.date_joined).toLocaleDateString('fr-FR') - : 'Non spécifié'} - -
-
- )} -
- -
+
+ Date d'inscription: + + {user.date_joined + ? new Date(user.date_joined).toLocaleDateString('fr-FR') + : 'Non spécifié'} + +
+
+ + ); }; diff --git a/frontend/src/pages/LoginPage.jsx b/frontend/src/pages/LoginPage.jsx index a7834eb..2d8db85 100644 --- a/frontend/src/pages/LoginPage.jsx +++ b/frontend/src/pages/LoginPage.jsx @@ -1,79 +1,125 @@ import React, { useState } from 'react'; import { useNavigate, useLocation } from 'react-router-dom'; import { useAuth } from '../context/useAuth'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import MainCard from '../components/MainCard'; +import { CardContent } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Button } from '@/components/ui/button'; -import { Alert, AlertDescription } from '@/components/ui/alert'; - +import { + Alert, + AlertDescription, +} from '@/components/ui/alert'; +import {KeyRound} from 'lucide-react'; const LoginPage = () => { const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const { login } = useAuth(); const navigate = useNavigate(); const location = useLocation(); - const from = location.state?.from?.pathname || "/"; + const from = + location.state?.from?.pathname || '/'; const handleSubmit = async (e) => { e.preventDefault(); + + if (loading) { + return; + } + setError(''); + setLoading(true); + try { await login(username, password); navigate(from, { replace: true }); } catch (err) { - setError('Nom d\'utilisateur ou mot de passe incorrect'); + setError( + 'Nom d\'utilisateur ou mot de passe incorrect', + ); console.error(err); + } finally { + setLoading(false); } }; return ( -
- - - Connexion - - - {error && ( - - {error} - - )} -
-
- - setUsername(e.target.value)} - required - /> -
-
- - setPassword(e.target.value)} - required - /> -
- -
-
-
-
+ + + {error && ( + + + {error} + + + )} + +
+
+ + + + setUsername(e.target.value) + } + disabled={loading} + required + /> +
+ +
+ + + + setPassword(e.target.value) + } + disabled={loading} + required + /> +
+ + +
+
+
); }; -export default LoginPage; - +export default LoginPage; \ No newline at end of file diff --git a/frontend/src/pages/RegisterPage.jsx b/frontend/src/pages/RegisterPage.jsx index 0642c92..5fc7d55 100644 --- a/frontend/src/pages/RegisterPage.jsx +++ b/frontend/src/pages/RegisterPage.jsx @@ -1,15 +1,23 @@ import { useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { useAuth } from '../context/useAuth'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import MainCard from '../components/MainCard'; +import { CardContent } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Button } from '@/components/ui/button'; -import { Alert, AlertDescription } from '@/components/ui/alert'; +import { + Alert, + AlertDescription, +} from '@/components/ui/alert'; +import { UserKey, Ban, Check} from 'lucide-react'; const RegisterPage = () => { - const invite = new URLSearchParams(useLocation().search).get('invite') || ''; + const invite = + new URLSearchParams(useLocation().search).get('invite') || ''; + const navigate = useNavigate(); const { register } = useAuth(); + const [form, setForm] = useState({ first_name: '', last_name: '', @@ -17,135 +25,206 @@ const RegisterPage = () => { password: '', password_confirm: '', }); + const [error, setError] = useState(''); const [isCreated, setIsCreated] = useState(false); + const [loading, setLoading] = useState(false); const handleSubmit = async (event) => { event.preventDefault(); + + if (loading) { + return; + } + setError(''); + setLoading(true); + try { await register({ ...form, invite }); setIsCreated(true); } catch (requestError) { - // Safely handle the error response structure const detail = requestError.response?.data; - const errorMessage = requestError.response?.data?.detail || requestError.message || 'Impossible de créer le compte.'; - + const errorMessage = + requestError.response?.data?.detail || + requestError.message || + 'Impossible de créer le compte.'; + setError( detail?.invite?.[0] - || detail?.first_name?.[0] - || detail?.last_name?.[0] - || detail?.username?.[0] - || detail?.password?.[0] - || detail?.password_confirm?.[0] - || errorMessage, + || detail?.first_name?.[0] + || detail?.last_name?.[0] + || detail?.username?.[0] + || detail?.password?.[0] + || detail?.password_confirm?.[0] + || errorMessage, ); + } finally { + setLoading(false); } }; if (!invite) { return ( -
- - - Lien invalide - - -

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

-
-
-
+ + + 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.

- -
-
-
+ + + Votre compte est prêt. Vous pouvez maintenant vous connecter. + + + ); } return ( -
- - - Créer un compte - - - {error && ( - - {error} - - )} -
-
- - setForm({ ...form, first_name: event.target.value })} - /> -
-
- - setForm({ ...form, last_name: event.target.value })} - /> -
-
- - setForm({ ...form, username: event.target.value })} - /> -
-
- - setForm({ ...form, password: event.target.value })} - /> -
-
- - setForm({ ...form, password_confirm: event.target.value })} - /> -
- -
-
-
-
+ + + {error && ( + + + {error} + + + )} + +
+
+ + + + setForm({ + ...form, + first_name: event.target.value, + }) + } + /> +
+ +
+ + + + setForm({ + ...form, + last_name: event.target.value, + }) + } + /> +
+ +
+ + + + setForm({ + ...form, + username: event.target.value, + }) + } + /> +
+ +
+ + + + setForm({ + ...form, + password: event.target.value, + }) + } + /> +
+ +
+ + + + setForm({ + ...form, + password_confirm: event.target.value, + }) + } + /> +
+ + +
+
+
); }; -export default RegisterPage; +export default RegisterPage; \ No newline at end of file diff --git a/frontend/src/pages/TimeClockPage.jsx b/frontend/src/pages/TimeClockPage.jsx index d75dfde..49acc60 100644 --- a/frontend/src/pages/TimeClockPage.jsx +++ b/frontend/src/pages/TimeClockPage.jsx @@ -2,7 +2,8 @@ import { useEffect, useRef, useState } from 'react'; import { BrowserQRCodeReader } from '@zxing/browser'; import { timeApi } from '../api/time'; import { useAuth } from '../context/useAuth'; - +import LoadingCard from '@/components/LoadingCard'; +import MainCard from '@/components/MainCard'; import { Card, CardContent, @@ -12,7 +13,6 @@ import { import { Label } from '@/components/ui/label'; import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; import { Alert, @@ -121,13 +121,36 @@ const formatDateLabel = (value) => { return 'Choisir une date'; } - return date.toLocaleDateString( - 'fr-FR', - { - day: '2-digit', - month: 'long', - year: 'numeric', - }, + const numericDate = + date.toLocaleDateString( + 'fr-FR', + { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }, + ); + + const longDate = + date.toLocaleDateString( + 'fr-FR', + { + day: '2-digit', + month: 'long', + year: 'numeric', + }, + ); + + return ( + <> + + {longDate} + + + + {numericDate} + + ); }; @@ -334,8 +357,7 @@ const DatePicker = ({ const TimePicker = ({ value, - onChange, - placeholder = 'Choisir une heure', + onChange }) => { const [open, setOpen] = useState(false); @@ -424,7 +446,6 @@ const TimePicker = ({ ); }; - // PC : une molette = une unité const handleWheel = ( event, type, @@ -442,7 +463,6 @@ const TimePicker = ({ } }; - // MOBILE : début du déplacement const handleTouchStart = (event) => { const touch = event.touches[0]; @@ -453,7 +473,6 @@ const TimePicker = ({ }; }; - // MOBILE : déplacement continu const handleTouchMove = ( event, type, @@ -530,7 +549,19 @@ const TimePicker = ({ : 'text-muted-foreground' } > - {value || placeholder} + {value ? ( + value + ) : ( + <> + + Choisir une heure + + + + Choisir + + + )} @@ -551,7 +582,6 @@ const TimePicker = ({
- {/* HEURES */}
@@ -597,14 +627,12 @@ const TimePicker = ({
- {/* : */}
:
- {/* MINUTES */}
@@ -739,6 +767,11 @@ const TimeClockPage = () => { setLoading, ] = useState(true); + const [ + initialLoading, + setInitialLoading, + ] = useState(true); + const [ isScannerDialogOpen, setIsScannerDialogOpen, @@ -1345,6 +1378,7 @@ const TimeClockPage = () => { console.error(err); } finally { setLoading(false); + setInitialLoading(false); } }; @@ -2446,6 +2480,21 @@ const TimeClockPage = () => { ); + /* ========================================================= + INITIAL LOADING + ========================================================= */ + + if (initialLoading) { + return ( + + ); + } + + /* ========================================================= RENDER ========================================================= */ @@ -2454,25 +2503,10 @@ const TimeClockPage = () => { getScanConflictConfig(); return ( - + - - - Pointage - - - - - {/* IMPORTANT : - flex + flex-col + min-h-0 permettent à la grille - de récupérer la hauteur restante sur desktop. - Mobile garde le scroll global. */} - {/* ========================= - ALERTES - ========================== */} - {error && ( { }

-
+
-
- -
+
@@ -2709,8 +2741,8 @@ const TimeClockPage = () => { ====================================================== */}
@@ -2721,7 +2753,6 @@ const TimeClockPage = () => { {isAdmin && (
- @@ -2730,82 +2761,33 @@ const TimeClockPage = () => { - + - {/* UTILISATEUR */} + -
- - - - + + + Voir les pointages de : + + - - -
+ - {/* SEPARATION */} +