clean
This commit is contained in:
parent
876ec75a4e
commit
5298d69e1f
@ -45,6 +45,7 @@ INSTALLED_APPS = [
|
|||||||
# Local apps
|
# Local apps
|
||||||
'users',
|
'users',
|
||||||
'time_tracking',
|
'time_tracking',
|
||||||
|
'events',
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
@ -155,3 +156,4 @@ SIMPLE_JWT = {
|
|||||||
# Default primary key field type
|
# Default primary key field type
|
||||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
|
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -27,4 +27,5 @@ urlpatterns = [
|
|||||||
path('api/auth/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
|
path('api/auth/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
|
||||||
path('api/users/', include('users.urls')),
|
path('api/users/', include('users.urls')),
|
||||||
path('api/time/', include('time_tracking.urls')),
|
path('api/time/', include('time_tracking.urls')),
|
||||||
|
path('api/event/', include('events.urls')),
|
||||||
]
|
]
|
||||||
|
|||||||
33
backend/events/migrations/0001_initial.py
Normal file
33
backend/events/migrations/0001_initial.py
Normal file
@ -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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
0
backend/events/migrations/__init__.py
Normal file
0
backend/events/migrations/__init__.py
Normal file
30
backend/events/models.py
Normal file
30
backend/events/models.py
Normal file
@ -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
|
||||||
54
backend/events/serializers.py
Normal file
54
backend/events/serializers.py
Normal file
@ -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'
|
||||||
|
]
|
||||||
8
backend/events/urls.py
Normal file
8
backend/events/urls.py
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
from django.urls import path
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('', views.EventListView.as_view(), name='event-list'),
|
||||||
|
path('<int:id>/', views.EventDetailView.as_view(), name='event-detail'),
|
||||||
|
path('create/', views.EventCreateView.as_view(), name='event-create'),
|
||||||
|
]
|
||||||
57
backend/events/views.py
Normal file
57
backend/events/views.py
Normal file
@ -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)
|
||||||
@ -4,24 +4,13 @@ from uuid import uuid4
|
|||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
from users.serializers import UserSerializer
|
||||||
from .models import TimeEntry
|
from .models import TimeEntry
|
||||||
|
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
class UserSerializer(serializers.ModelSerializer):
|
|
||||||
class Meta:
|
|
||||||
model = User
|
|
||||||
fields = [
|
|
||||||
'id',
|
|
||||||
'username',
|
|
||||||
'first_name',
|
|
||||||
'last_name',
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class TimeEntrySerializer(serializers.ModelSerializer):
|
class TimeEntrySerializer(serializers.ModelSerializer):
|
||||||
user = UserSerializer(read_only=True)
|
user = UserSerializer(read_only=True)
|
||||||
|
|
||||||
|
|||||||
@ -69,7 +69,7 @@ class UsersConfig(AppConfig):
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Don't fail the entire Django startup if there's an error
|
# 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
|
# Start the superuser creation in a separate thread to avoid the warning
|
||||||
thread = threading.Thread(target=create_superuser_if_needed, daemon=True)
|
thread = threading.Thread(target=create_superuser_if_needed, daemon=True)
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import AdminUsersPage from './pages/AdminUsersPage';
|
|||||||
import RegisterPage from './pages/RegisterPage';
|
import RegisterPage from './pages/RegisterPage';
|
||||||
import TimeClockPage from './pages/TimeClockPage';
|
import TimeClockPage from './pages/TimeClockPage';
|
||||||
import WeeklySummaryPage from './pages/WeeklySummaryPage';
|
import WeeklySummaryPage from './pages/WeeklySummaryPage';
|
||||||
|
import CalendarPage from './pages/CalendarPage';
|
||||||
import { useAuth } from './context/useAuth';
|
import { useAuth } from './context/useAuth';
|
||||||
|
|
||||||
// Composant personnalisé pour le trigger mobile avec icône menu
|
// Composant personnalisé pour le trigger mobile avec icône menu
|
||||||
@ -108,6 +109,15 @@ function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/calendar"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<CalendarPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="*"
|
path="*"
|
||||||
element={<Navigate to="/" replace />}
|
element={<Navigate to="/" replace />}
|
||||||
|
|||||||
58
frontend/src/api/event.js
Normal file
58
frontend/src/api/event.js
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
36
frontend/src/components/LoadingCard.jsx
Normal file
36
frontend/src/components/LoadingCard.jsx
Normal file
@ -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 (
|
||||||
|
<Card className="flex h-full min-h-0 w-full flex-col overflow-hidden border-l-0">
|
||||||
|
<CardHeader className="shrink-0 border-b">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
{Icon && <Icon className="h-5 w-5" />}
|
||||||
|
{title}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="flex min-h-0 flex-1 items-center justify-center">
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-blue-500" />
|
||||||
|
|
||||||
|
<span className="ml-3">
|
||||||
|
{message}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoadingCard;
|
||||||
39
frontend/src/components/MainCard.jsx
Normal file
39
frontend/src/components/MainCard.jsx
Normal file
@ -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 (
|
||||||
|
<Card
|
||||||
|
className={`flex h-full w-full flex-col border-l-0 ${className}`}
|
||||||
|
>
|
||||||
|
<CardHeader className="flex shrink-0 flex-row items-center justify-between border-b">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
{Icon && <Icon className="h-5 w-5" />}
|
||||||
|
{title}
|
||||||
|
</CardTitle>
|
||||||
|
|
||||||
|
{headerContent && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{headerContent}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MainCard;
|
||||||
@ -22,6 +22,7 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
LogOut,
|
LogOut,
|
||||||
Wheat,
|
Wheat,
|
||||||
|
Calendar,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
const NavBar = () => {
|
const NavBar = () => {
|
||||||
@ -74,27 +75,17 @@ const NavBar = () => {
|
|||||||
icon: User,
|
icon: User,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Résumé hebdomadaire',
|
title: 'Planning',
|
||||||
href: '/weekly-summary',
|
href: '/weekly-summary',
|
||||||
icon: Clock,
|
icon: CalendarDays,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Pointage',
|
title: 'Pointage',
|
||||||
href: '/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 menuItemClass = 'h-12 min-h-12 flex items-center';
|
||||||
|
|
||||||
const buttonClass = `
|
const buttonClass = `
|
||||||
@ -114,17 +105,6 @@ const NavBar = () => {
|
|||||||
text-base
|
text-base
|
||||||
`;
|
`;
|
||||||
|
|
||||||
/*
|
|
||||||
* Header de groupe :
|
|
||||||
*
|
|
||||||
* Déplié :
|
|
||||||
* [ Navigation ] 32px
|
|
||||||
*
|
|
||||||
* Plié :
|
|
||||||
* [ ---------------------- ] 32px
|
|
||||||
*
|
|
||||||
* Même hauteur dans les deux cas.
|
|
||||||
*/
|
|
||||||
const GroupHeader = ({ children }) => (
|
const GroupHeader = ({ children }) => (
|
||||||
<div
|
<div
|
||||||
className="
|
className="
|
||||||
@ -247,6 +227,37 @@ const NavBar = () => {
|
|||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
|
|
||||||
|
{/* Entreprise */}
|
||||||
|
<SidebarGroup className="p-2">
|
||||||
|
<GroupHeader>
|
||||||
|
Entreprise
|
||||||
|
</GroupHeader>
|
||||||
|
|
||||||
|
<SidebarGroupContent>
|
||||||
|
<SidebarMenu>
|
||||||
|
<SidebarMenuItem className={menuItemClass}>
|
||||||
|
<SidebarMenuButton
|
||||||
|
tooltip="Calendrier"
|
||||||
|
onClick={handleCloseMenu}
|
||||||
|
className={buttonClass}
|
||||||
|
render={
|
||||||
|
<NavLink
|
||||||
|
to="/calendar"
|
||||||
|
className={linkClass}
|
||||||
|
>
|
||||||
|
<Calendar className="size-6 shrink-0" />
|
||||||
|
|
||||||
|
<span className="truncate text-base">
|
||||||
|
Calendrier
|
||||||
|
</span>
|
||||||
|
</NavLink>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
|
|
||||||
{/* Administration */}
|
{/* Administration */}
|
||||||
{user.is_admin && (
|
{user.is_admin && (
|
||||||
<SidebarGroup className="p-2">
|
<SidebarGroup className="p-2">
|
||||||
@ -269,7 +280,7 @@ const NavBar = () => {
|
|||||||
<Users className="size-6 shrink-0" />
|
<Users className="size-6 shrink-0" />
|
||||||
|
|
||||||
<span className="truncate text-base">
|
<span className="truncate text-base">
|
||||||
Comptes existants
|
Utilisateurs
|
||||||
</span>
|
</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,7 +11,7 @@ function Card({
|
|||||||
data-slot="card"
|
data-slot="card"
|
||||||
data-size={size}
|
data-size={size}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { userApi } from '../api/users';
|
import { userApi } from '../api/users';
|
||||||
|
import LoadingCard from '@/components/LoadingCard';
|
||||||
|
import MainCard from '@/components/MainCard';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@ -10,7 +12,7 @@ import {
|
|||||||
TableRow
|
TableRow
|
||||||
} from '@/components/ui/table';
|
} from '@/components/ui/table';
|
||||||
import { Input } from '@/components/ui/input';
|
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 { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { useAuth } from '../context/useAuth';
|
import { useAuth } from '../context/useAuth';
|
||||||
|
import { Users} from 'lucide-react';
|
||||||
const AdminUsersPage = () => {
|
const AdminUsersPage = () => {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
const [users, setUsers] = useState([]);
|
const [users, setUsers] = useState([]);
|
||||||
@ -147,23 +149,16 @@ const AdminUsersPage = () => {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<Card className="page-container page-layout admin-page">
|
<LoadingCard
|
||||||
<CardHeader className="border-b">
|
icon={Users}
|
||||||
<CardTitle>Gestion des comptes</CardTitle>
|
title="Gestion des utilisateurs"
|
||||||
</CardHeader>
|
message="Chargement des utilisateurs..."
|
||||||
<CardContent>
|
/>
|
||||||
<p>Chargement des utilisateurs...</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="page-container page-layout admin-page h-full w-full">
|
<MainCard title="Gestion des utilisateurs" icon={Users}>
|
||||||
<CardHeader className="border-b">
|
|
||||||
<CardTitle>Gestion des comptes</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="overflow-y-auto">
|
<CardContent className="overflow-y-auto">
|
||||||
{error && (
|
{error && (
|
||||||
<Alert variant="destructive" className="mb-4">
|
<Alert variant="destructive" className="mb-4">
|
||||||
@ -171,189 +166,271 @@ const AdminUsersPage = () => {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="overflow-x-auto mb-6">
|
<div className="flex h-9 w-full min-w-0 items-center gap-2">
|
||||||
<Table>
|
<Button
|
||||||
<TableHeader>
|
onClick={generateInviteLink}
|
||||||
<TableRow>
|
size="sm"
|
||||||
<TableHead>Nom</TableHead>
|
className="shrink-0"
|
||||||
<TableHead>Couleur</TableHead>
|
>
|
||||||
<TableHead>Rôle</TableHead>
|
Inviter un utilisateur
|
||||||
<TableHead>Inscrit le</TableHead>
|
</Button>
|
||||||
<TableHead>Actions</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{users.map((user) => (
|
|
||||||
<TableRow key={user.id}>
|
|
||||||
<TableCell>{user.first_name} {user.last_name}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: user.color || '#0000ff' }}></div>
|
|
||||||
<span>{user.color || 'Bleu'}</span>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant={user.is_admin ? "default" : "secondary"}>
|
|
||||||
{user.is_admin ? 'Administrateur' : 'Utilisateur'}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{new Date(user.date_joined).toLocaleDateString('fr-FR')}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Dialog open={editingUser === user.id} onOpenChange={(open) => {
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button variant="outline" size="sm" onClick={() => setEditingUser(user.id)}>
|
|
||||||
Éditer
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader className="border-b">
|
|
||||||
<DialogTitle>Modifier l'utilisateur</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="grid gap-4 py-4">
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor={`first-name-${user.id}`}>Prénom</Label>
|
|
||||||
<Input
|
|
||||||
id={`first-name-${user.id}`}
|
|
||||||
value={editForm.first_name}
|
|
||||||
onChange={(event) => setEditForm({ ...editForm, first_name: event.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor={`last-name-${user.id}`}>Nom</Label>
|
|
||||||
<Input
|
|
||||||
id={`last-name-${user.id}`}
|
|
||||||
value={editForm.last_name}
|
|
||||||
onChange={(event) => setEditForm({ ...editForm, last_name: event.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor={`email-${user.id}`}>E-mail</Label>
|
|
||||||
<Input
|
|
||||||
id={`email-${user.id}`}
|
|
||||||
type="email"
|
|
||||||
value={editForm.email}
|
|
||||||
onChange={(event) => setEditForm({ ...editForm, email: event.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor={`color-${user.id}`}>Couleur</Label>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Input
|
|
||||||
id={`color-${user.id}`}
|
|
||||||
type="color"
|
|
||||||
value={/^#[0-9A-Fa-f]{6}$/.test(editForm.color) ? editForm.color : '#000000'}
|
|
||||||
onChange={(event) => setEditForm({ ...editForm, color: event.target.value })}
|
|
||||||
className="h-10 w-14 cursor-pointer p-1"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
aria-label="Valeur hexadécimale de la couleur"
|
|
||||||
value={editForm.color}
|
|
||||||
pattern="^#[0-9A-Fa-f]{6}$"
|
|
||||||
onChange={(event) => setEditForm({ ...editForm, color: event.target.value })}
|
|
||||||
className="max-w-36 font-mono uppercase"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<label className="flex items-center gap-3 text-sm">
|
|
||||||
<Checkbox
|
|
||||||
checked={editForm.is_active}
|
|
||||||
onCheckedChange={(checked) => setEditForm({ ...editForm, is_active: checked })}
|
|
||||||
/>
|
|
||||||
Compte actif
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center gap-3 text-sm">
|
|
||||||
<Checkbox
|
|
||||||
checked={editForm.is_staff}
|
|
||||||
onCheckedChange={(checked) => setEditForm({
|
|
||||||
...editForm,
|
|
||||||
is_staff: checked,
|
|
||||||
is_superuser: checked ? editForm.is_superuser : false,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
Administrateur
|
|
||||||
</label>
|
|
||||||
<p className="text-sm text-muted-foreground">Le mot de passe ne peut pas être modifié ici.</p>
|
|
||||||
</div>
|
|
||||||
<Button onClick={() => handleEditUser(user.id)}>
|
|
||||||
Sauvegarder
|
|
||||||
</Button>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
{!user.is_admin && (
|
|
||||||
<ConfirmDialog
|
|
||||||
title="Promouvoir l'utilisateur"
|
|
||||||
description="Êtes-vous sûr de vouloir promouvoir cet utilisateur ?"
|
|
||||||
onConfirm={() => handleMakeAdmin(user.id)}
|
|
||||||
>
|
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
Promouvoir
|
|
||||||
</Button>
|
|
||||||
</ConfirmDialog>
|
|
||||||
)}
|
|
||||||
<ConfirmDialog
|
|
||||||
title="Supprimer l'utilisateur"
|
|
||||||
description="Êtes-vous sûr de vouloir supprimer cet utilisateur ?"
|
|
||||||
onConfirm={() => handleDeleteUser(user.id)}
|
|
||||||
>
|
|
||||||
<Button variant="destructive" size="sm">
|
|
||||||
Supprimer
|
|
||||||
</Button>
|
|
||||||
</ConfirmDialog>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
{inviteLink && (
|
||||||
{inviteLink ? (
|
<>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="min-w-0 flex-1">
|
||||||
<Button onClick={generateInviteLink}>
|
|
||||||
Inviter un utilisateur
|
|
||||||
</Button>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={inviteLink}
|
value={inviteLink}
|
||||||
readOnly
|
readOnly
|
||||||
className="flex-1"
|
className="h-9 w-full"
|
||||||
/>
|
/>
|
||||||
<Button
|
</div>
|
||||||
onClick={copyToClipboard}
|
|
||||||
variant="outline"
|
<Button
|
||||||
>
|
onClick={copyToClipboard}
|
||||||
{isCopied ? 'Copié!' : 'Copier'}
|
variant="outline"
|
||||||
</Button>
|
size="sm"
|
||||||
</div>
|
className="h-9 shrink-0"
|
||||||
</div>
|
>
|
||||||
) : (
|
{isCopied ? 'Copié !' : 'Copier'}
|
||||||
<Button onClick={generateInviteLink}>
|
</Button>
|
||||||
Inviter un utilisateur
|
</>
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto mb-6">
|
||||||
|
<Table className="w-full">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="text-center">Nom</TableHead>
|
||||||
|
<TableHead className="text-center">Couleur</TableHead>
|
||||||
|
<TableHead className="text-center">Rôle</TableHead>
|
||||||
|
<TableHead className="text-center">Inscrit le</TableHead>
|
||||||
|
<TableHead className="text-center">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
{users.map((user) => (
|
||||||
|
<TableRow key={user.id}>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
{user.first_name} {user.last_name}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<div
|
||||||
|
className="h-4 w-4 rounded-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor: user.color || '#0000ff'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>{user.color || 'Bleu'}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Badge variant={user.is_admin ? 'default' : 'secondary'}>
|
||||||
|
{user.is_admin ? 'Administrateur' : 'Utilisateur'}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="text-center">
|
||||||
|
{new Date(user.date_joined).toLocaleDateString('fr-FR')}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex justify-center gap-2">
|
||||||
|
<Dialog
|
||||||
|
open={editingUser === user.id}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setEditingUser(user.id)}
|
||||||
|
>
|
||||||
|
Éditer
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader className="border-b">
|
||||||
|
<DialogTitle>Modifier l'utilisateur</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor={`first-name-${user.id}`}>
|
||||||
|
Prénom
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={`first-name-${user.id}`}
|
||||||
|
value={editForm.first_name}
|
||||||
|
onChange={(event) =>
|
||||||
|
setEditForm({
|
||||||
|
...editForm,
|
||||||
|
first_name: event.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor={`last-name-${user.id}`}>
|
||||||
|
Nom
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={`last-name-${user.id}`}
|
||||||
|
value={editForm.last_name}
|
||||||
|
onChange={(event) =>
|
||||||
|
setEditForm({
|
||||||
|
...editForm,
|
||||||
|
last_name: event.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor={`email-${user.id}`}>E-mail</Label>
|
||||||
|
<Input
|
||||||
|
id={`email-${user.id}`}
|
||||||
|
type="email"
|
||||||
|
value={editForm.email}
|
||||||
|
onChange={(event) =>
|
||||||
|
setEditForm({
|
||||||
|
...editForm,
|
||||||
|
email: event.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor={`color-${user.id}`}>Couleur</Label>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Input
|
||||||
|
id={`color-${user.id}`}
|
||||||
|
type="color"
|
||||||
|
value={
|
||||||
|
/^#[0-9A-Fa-f]{6}$/.test(editForm.color)
|
||||||
|
? editForm.color
|
||||||
|
: '#000000'
|
||||||
|
}
|
||||||
|
onChange={(event) =>
|
||||||
|
setEditForm({
|
||||||
|
...editForm,
|
||||||
|
color: event.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="h-10 w-14 cursor-pointer p-1"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
aria-label="Valeur hexadécimale de la couleur"
|
||||||
|
value={editForm.color}
|
||||||
|
pattern="^#[0-9A-Fa-f]{6}$"
|
||||||
|
onChange={(event) =>
|
||||||
|
setEditForm({
|
||||||
|
...editForm,
|
||||||
|
color: event.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="max-w-36 font-mono uppercase"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-3 text-sm">
|
||||||
|
<Checkbox
|
||||||
|
checked={editForm.is_active}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setEditForm({
|
||||||
|
...editForm,
|
||||||
|
is_active: checked,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
Compte actif
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-3 text-sm">
|
||||||
|
<Checkbox
|
||||||
|
checked={editForm.is_staff}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setEditForm({
|
||||||
|
...editForm,
|
||||||
|
is_staff: checked,
|
||||||
|
is_superuser: checked
|
||||||
|
? editForm.is_superuser
|
||||||
|
: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
Administrateur
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Le mot de passe ne peut pas être modifié ici.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onClick={() => handleEditUser(user.id)}>
|
||||||
|
Sauvegarder
|
||||||
|
</Button>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{!user.is_admin && (
|
||||||
|
<ConfirmDialog
|
||||||
|
title="Promouvoir l'utilisateur"
|
||||||
|
description="Êtes-vous sûr de vouloir promouvoir cet utilisateur ?"
|
||||||
|
onConfirm={() => handleMakeAdmin(user.id)}
|
||||||
|
>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
Promouvoir
|
||||||
|
</Button>
|
||||||
|
</ConfirmDialog>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
title="Supprimer l'utilisateur"
|
||||||
|
description="Êtes-vous sûr de vouloir supprimer cet utilisateur ?"
|
||||||
|
onConfirm={() => handleDeleteUser(user.id)}
|
||||||
|
>
|
||||||
|
<Button variant="destructive" size="sm">
|
||||||
|
Supprimer
|
||||||
|
</Button>
|
||||||
|
</ConfirmDialog>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</MainCard>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
366
frontend/src/pages/CalendarPage.jsx
Normal file
366
frontend/src/pages/CalendarPage.jsx
Normal file
@ -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 (
|
||||||
|
<MainCard title="Calendrier annuel" icon={Calendar} headerContent={
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={previousYear}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
{!isMobile && 'Précédente'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={currentYear}
|
||||||
|
>
|
||||||
|
{year}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={nextYear}
|
||||||
|
>
|
||||||
|
{!isMobile && 'Suivante'}
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</>}>
|
||||||
|
|
||||||
|
<CardContent className="flex min-h-0 flex-1 flex-col overflow-hidden p-4">
|
||||||
|
<Card className="flex min-h-0 flex-1 flex-col overflow-hidden p-0">
|
||||||
|
<div
|
||||||
|
ref={calendarRef}
|
||||||
|
className="relative min-h-0 flex-1 overflow-auto p-0"
|
||||||
|
style={{
|
||||||
|
WebkitOverflowScrolling: 'touch',
|
||||||
|
touchAction: 'pan-x pan-y',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="grid h-fit w-full min-w-0 border-l border-t border-border"
|
||||||
|
style={{
|
||||||
|
minWidth: `${minimumGridWidth}px`,
|
||||||
|
gridTemplateColumns: `
|
||||||
|
${DAY_COLUMN_WIDTH}px
|
||||||
|
repeat(12, minmax(${MIN_MONTH_WIDTH}px, 1fr))
|
||||||
|
`,
|
||||||
|
gridTemplateRows: `
|
||||||
|
${HEADER_HEIGHT}px
|
||||||
|
repeat(31, ${rowHeight}px)
|
||||||
|
`,
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="
|
||||||
|
sticky left-0 top-0 z-30
|
||||||
|
flex items-center justify-center
|
||||||
|
border-b border-r border-border
|
||||||
|
bg-background
|
||||||
|
px-2
|
||||||
|
text-xs font-medium
|
||||||
|
text-muted-foreground
|
||||||
|
"
|
||||||
|
style={{ height: `${HEADER_HEIGHT}px` }}
|
||||||
|
>
|
||||||
|
Jour
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{MONTHS.map((month) => (
|
||||||
|
<div
|
||||||
|
key={month}
|
||||||
|
className="
|
||||||
|
sticky top-0 z-20
|
||||||
|
flex items-center justify-center
|
||||||
|
border-b border-r border-border
|
||||||
|
bg-background
|
||||||
|
px-3
|
||||||
|
text-center
|
||||||
|
text-sm
|
||||||
|
font-medium
|
||||||
|
"
|
||||||
|
style={{ height: `${HEADER_HEIGHT}px` }}
|
||||||
|
>
|
||||||
|
{month}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{Array.from({ length: 31 }, (_, dayIndex) => {
|
||||||
|
const day = dayIndex + 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={day}>
|
||||||
|
<div
|
||||||
|
className="
|
||||||
|
sticky left-0 z-10
|
||||||
|
flex items-center justify-center
|
||||||
|
border-b border-r border-border
|
||||||
|
bg-card
|
||||||
|
px-2
|
||||||
|
text-xs font-medium
|
||||||
|
text-muted-foreground
|
||||||
|
"
|
||||||
|
style={{ height: `${rowHeight}px` }}
|
||||||
|
>
|
||||||
|
{day}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{MONTHS.map((month, monthIndex) => {
|
||||||
|
const valid =
|
||||||
|
day <= getDaysInMonth(monthIndex);
|
||||||
|
|
||||||
|
const todayCell =
|
||||||
|
valid && isToday(monthIndex, day);
|
||||||
|
|
||||||
|
if (!valid) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${monthIndex}-${day}`}
|
||||||
|
className="
|
||||||
|
border-b border-r border-border
|
||||||
|
bg-transparent
|
||||||
|
"
|
||||||
|
style={{
|
||||||
|
height: `${rowHeight}px`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${monthIndex}-${day}`}
|
||||||
|
className={`
|
||||||
|
relative
|
||||||
|
border-b border-r border-border
|
||||||
|
bg-card
|
||||||
|
px-2
|
||||||
|
transition-colors
|
||||||
|
hover:bg-muted/40
|
||||||
|
${todayCell ? 'bg-muted' : ''}
|
||||||
|
`}
|
||||||
|
style={{
|
||||||
|
height: `${rowHeight}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{todayCell && (
|
||||||
|
<div
|
||||||
|
className="
|
||||||
|
absolute
|
||||||
|
left-1/2
|
||||||
|
top-1/2
|
||||||
|
flex
|
||||||
|
h-6
|
||||||
|
min-w-6
|
||||||
|
-translate-x-1/2
|
||||||
|
-translate-y-1/2
|
||||||
|
items-center
|
||||||
|
justify-center
|
||||||
|
rounded-full
|
||||||
|
bg-primary
|
||||||
|
px-1
|
||||||
|
text-[11px]
|
||||||
|
font-semibold
|
||||||
|
text-primary-foreground
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{day}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</CardContent>
|
||||||
|
</MainCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CalendarPage;
|
||||||
@ -1,65 +1,73 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import LoadingCard from '@/components/LoadingCard';
|
||||||
|
import MainCard from '@/components/MainCard';
|
||||||
import { useAuth } from '../context/useAuth';
|
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 { Badge } from '@/components/ui/badge';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { User } from 'lucide-react';
|
||||||
|
|
||||||
const HomePage = () => {
|
const HomePage = () => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<LoadingCard
|
||||||
|
icon={User}
|
||||||
|
title="Profil"
|
||||||
|
message="Chargement du profil..."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full">
|
<MainCard title="Profil" icon={User}>
|
||||||
<Card className="mx-auto h-full w-full">
|
|
||||||
<CardHeader className="border-b">
|
|
||||||
<CardTitle>Profil Utilisateur</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent overflow-y-auto>
|
<CardContent className="overflow-y-auto">
|
||||||
{user && (
|
<div className="space-y-4">
|
||||||
<div className="space-y-4">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center justify-between">
|
<span className="font-medium">Prénom:</span>
|
||||||
<span className="font-medium">Prénom:</span>
|
<span>{user.first_name}</span>
|
||||||
<span>{user.first_name}</span>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="font-medium">Nom:</span>
|
<span className="font-medium">Nom:</span>
|
||||||
<span>{user.last_name}</span>
|
<span>{user.last_name}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="font-medium">Admin:</span>
|
<span className="font-medium">Admin:</span>
|
||||||
<Badge variant={user.is_admin ? 'default' : 'secondary'}>
|
<Badge variant={user.is_admin ? 'default' : 'secondary'}>
|
||||||
{user.is_admin ? 'Oui' : 'Non'}
|
{user.is_admin ? 'Oui' : 'Non'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="font-medium">Nom d'utilisateur:</span>
|
<span className="font-medium">Nom d'utilisateur:</span>
|
||||||
<span>{user.username}</span>
|
<span>{user.username}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="font-medium">Date d'inscription:</span>
|
<span className="font-medium">Date d'inscription:</span>
|
||||||
<span>
|
<span>
|
||||||
{user.date_joined
|
{user.date_joined
|
||||||
? new Date(user.date_joined).toLocaleDateString('fr-FR')
|
? new Date(user.date_joined).toLocaleDateString('fr-FR')
|
||||||
: 'Non spécifié'}
|
: 'Non spécifié'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</CardContent>
|
||||||
</CardContent>
|
</MainCard>
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,79 +1,125 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { useAuth } from '../context/useAuth';
|
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 { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Button } from '@/components/ui/button';
|
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 LoginPage = () => {
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const { login } = useAuth();
|
const { login } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const from = location.state?.from?.pathname || "/";
|
const from =
|
||||||
|
location.state?.from?.pathname || '/';
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setError('');
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await login(username, password);
|
await login(username, password);
|
||||||
navigate(from, { replace: true });
|
navigate(from, { replace: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Nom d\'utilisateur ou mot de passe incorrect');
|
setError(
|
||||||
|
'Nom d\'utilisateur ou mot de passe incorrect',
|
||||||
|
);
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="auth-page page-layout flex h-full w-full">
|
<MainCard title="Connexion" icon={KeyRound}>
|
||||||
<Card className="flex h-full min-h-0 w-full overflow-y-auto">
|
<CardContent>
|
||||||
<CardHeader className="border-b">
|
{error && (
|
||||||
<CardTitle>Connexion</CardTitle>
|
<Alert
|
||||||
</CardHeader>
|
variant="destructive"
|
||||||
<CardContent>
|
className="mb-4"
|
||||||
{error && (
|
>
|
||||||
<Alert variant="destructive" className="mb-4">
|
<AlertDescription>
|
||||||
<AlertDescription>{error}</AlertDescription>
|
{error}
|
||||||
</Alert>
|
</AlertDescription>
|
||||||
)}
|
</Alert>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
)}
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="username">Nom d'utilisateur:</Label>
|
<form
|
||||||
<Input
|
onSubmit={handleSubmit}
|
||||||
id="username"
|
className="space-y-4"
|
||||||
type="text"
|
>
|
||||||
autoComplete="username"
|
<div className="space-y-2">
|
||||||
value={username}
|
<Label htmlFor="username">
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
Nom d'utilisateur:
|
||||||
required
|
</Label>
|
||||||
/>
|
|
||||||
</div>
|
<Input
|
||||||
<div className="space-y-2">
|
id="username"
|
||||||
<Label htmlFor="password">Mot de passe:</Label>
|
type="text"
|
||||||
<Input
|
autoComplete="username"
|
||||||
className="current-password"
|
value={username}
|
||||||
id="password"
|
onChange={(e) =>
|
||||||
type="password"
|
setUsername(e.target.value)
|
||||||
autoComplete="current-password"
|
}
|
||||||
value={password}
|
disabled={loading}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
required
|
||||||
required
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
|
||||||
<Button type="submit" className="w-full">
|
<div className="space-y-2">
|
||||||
Se connecter
|
<Label htmlFor="password">
|
||||||
</Button>
|
Mot de passe:
|
||||||
</form>
|
</Label>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
<Input
|
||||||
</div>
|
className="current-password"
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) =>
|
||||||
|
setPassword(e.target.value)
|
||||||
|
}
|
||||||
|
disabled={loading}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<span className="flex items-center justify-center gap-2">
|
||||||
|
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||||
|
Connexion...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'Se connecter'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</MainCard>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default LoginPage;
|
export default LoginPage;
|
||||||
|
|
||||||
|
|||||||
@ -1,15 +1,23 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../context/useAuth';
|
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 { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Button } from '@/components/ui/button';
|
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 RegisterPage = () => {
|
||||||
const invite = new URLSearchParams(useLocation().search).get('invite') || '';
|
const invite =
|
||||||
|
new URLSearchParams(useLocation().search).get('invite') || '';
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { register } = useAuth();
|
const { register } = useAuth();
|
||||||
|
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
first_name: '',
|
first_name: '',
|
||||||
last_name: '',
|
last_name: '',
|
||||||
@ -17,134 +25,205 @@ const RegisterPage = () => {
|
|||||||
password: '',
|
password: '',
|
||||||
password_confirm: '',
|
password_confirm: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [isCreated, setIsCreated] = useState(false);
|
const [isCreated, setIsCreated] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const handleSubmit = async (event) => {
|
const handleSubmit = async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setError('');
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await register({ ...form, invite });
|
await register({ ...form, invite });
|
||||||
setIsCreated(true);
|
setIsCreated(true);
|
||||||
} catch (requestError) {
|
} catch (requestError) {
|
||||||
// Safely handle the error response structure
|
|
||||||
const detail = requestError.response?.data;
|
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(
|
setError(
|
||||||
detail?.invite?.[0]
|
detail?.invite?.[0]
|
||||||
|| detail?.first_name?.[0]
|
|| detail?.first_name?.[0]
|
||||||
|| detail?.last_name?.[0]
|
|| detail?.last_name?.[0]
|
||||||
|| detail?.username?.[0]
|
|| detail?.username?.[0]
|
||||||
|| detail?.password?.[0]
|
|| detail?.password?.[0]
|
||||||
|| detail?.password_confirm?.[0]
|
|| detail?.password_confirm?.[0]
|
||||||
|| errorMessage,
|
|| errorMessage,
|
||||||
);
|
);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!invite) {
|
if (!invite) {
|
||||||
return (
|
return (
|
||||||
<div className="auth-page page-layout flex h-full w-full">
|
<MainCard title="Lien invalide" icon={Ban}>
|
||||||
<Card className="flex h-full w-full">
|
<CardContent>
|
||||||
<CardHeader className="border-b">
|
Un lien d’invitation est nécessaire pour créer un compte.
|
||||||
<CardTitle>Lien invalide</CardTitle>
|
</CardContent>
|
||||||
</CardHeader>
|
</MainCard>
|
||||||
<CardContent>
|
|
||||||
<p>Un lien d’invitation est nécessaire pour créer un compte.</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isCreated) {
|
if (isCreated) {
|
||||||
return (
|
return (
|
||||||
<div className="auth-page page-layout flex h-full w-full">
|
<MainCard title="Compte créé" icon={Check}>
|
||||||
<Card className="flex h-full w-full">
|
<CardContent>
|
||||||
<CardHeader className="border-b">
|
Votre compte est prêt. Vous pouvez maintenant vous connecter.
|
||||||
<CardTitle>Compte créé</CardTitle>
|
<Button
|
||||||
</CardHeader>
|
onClick={() => navigate('/login')}
|
||||||
<CardContent>
|
className="mt-4"
|
||||||
<p>Votre compte est prêt. Vous pouvez maintenant vous connecter.</p>
|
>
|
||||||
<Button onClick={() => navigate('/login')} className="mt-4">
|
Se connecter
|
||||||
Se connecter
|
</Button>
|
||||||
</Button>
|
</CardContent>
|
||||||
</CardContent>
|
</MainCard>
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="auth-page page-layout">
|
<MainCard title="Créer un compte" icon={UserKey}>
|
||||||
<Card className="mx-auto w-full max-w-md overflow-y-auto">
|
<CardContent>
|
||||||
<CardHeader className="border-b">
|
{error && (
|
||||||
<CardTitle>Créer un compte</CardTitle>
|
<Alert
|
||||||
</CardHeader>
|
variant="destructive"
|
||||||
<CardContent>
|
className="mb-4"
|
||||||
{error && (
|
>
|
||||||
<Alert variant="destructive" className="mb-4">
|
<AlertDescription>
|
||||||
<AlertDescription>{error}</AlertDescription>
|
{error}
|
||||||
</Alert>
|
</AlertDescription>
|
||||||
)}
|
</Alert>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
)}
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="first-name">Prénom</Label>
|
<form
|
||||||
<Input
|
onSubmit={handleSubmit}
|
||||||
id="first-name"
|
className="space-y-4"
|
||||||
required
|
>
|
||||||
value={form.first_name}
|
<div className="space-y-2">
|
||||||
onChange={(event) => setForm({ ...form, first_name: event.target.value })}
|
<Label htmlFor="first-name">
|
||||||
/>
|
Prénom
|
||||||
</div>
|
</Label>
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="last-name">Nom</Label>
|
<Input
|
||||||
<Input
|
id="first-name"
|
||||||
id="last-name"
|
required
|
||||||
required
|
disabled={loading}
|
||||||
value={form.last_name}
|
value={form.first_name}
|
||||||
onChange={(event) => setForm({ ...form, last_name: event.target.value })}
|
onChange={(event) =>
|
||||||
/>
|
setForm({
|
||||||
</div>
|
...form,
|
||||||
<div className="space-y-2">
|
first_name: event.target.value,
|
||||||
<Label htmlFor="username">Nom d'utilisateur</Label>
|
})
|
||||||
<Input
|
}
|
||||||
id="username"
|
/>
|
||||||
required
|
</div>
|
||||||
value={form.username}
|
|
||||||
onChange={(event) => setForm({ ...form, username: event.target.value })}
|
<div className="space-y-2">
|
||||||
/>
|
<Label htmlFor="last-name">
|
||||||
</div>
|
Nom
|
||||||
<div className="space-y-2">
|
</Label>
|
||||||
<Label htmlFor="password">Mot de passe</Label>
|
|
||||||
<Input
|
<Input
|
||||||
id="password"
|
id="last-name"
|
||||||
type="password"
|
required
|
||||||
required
|
disabled={loading}
|
||||||
minLength="8"
|
value={form.last_name}
|
||||||
value={form.password}
|
onChange={(event) =>
|
||||||
onChange={(event) => setForm({ ...form, password: event.target.value })}
|
setForm({
|
||||||
/>
|
...form,
|
||||||
</div>
|
last_name: event.target.value,
|
||||||
<div className="space-y-2">
|
})
|
||||||
<Label htmlFor="password-confirm">Confirmer le mot de passe</Label>
|
}
|
||||||
<Input
|
/>
|
||||||
id="password-confirm"
|
</div>
|
||||||
type="password"
|
|
||||||
required
|
<div className="space-y-2">
|
||||||
minLength="8"
|
<Label htmlFor="username">
|
||||||
value={form.password_confirm}
|
Nom d'utilisateur
|
||||||
onChange={(event) => setForm({ ...form, password_confirm: event.target.value })}
|
</Label>
|
||||||
/>
|
|
||||||
</div>
|
<Input
|
||||||
<Button type="submit" className="w-full">
|
id="username"
|
||||||
Créer mon compte
|
required
|
||||||
</Button>
|
disabled={loading}
|
||||||
</form>
|
value={form.username}
|
||||||
</CardContent>
|
onChange={(event) =>
|
||||||
</Card>
|
setForm({
|
||||||
</div>
|
...form,
|
||||||
|
username: event.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">
|
||||||
|
Mot de passe
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
minLength="8"
|
||||||
|
disabled={loading}
|
||||||
|
value={form.password}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm({
|
||||||
|
...form,
|
||||||
|
password: event.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password-confirm">
|
||||||
|
Confirmer le mot de passe
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
id="password-confirm"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
minLength="8"
|
||||||
|
disabled={loading}
|
||||||
|
value={form.password_confirm}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm({
|
||||||
|
...form,
|
||||||
|
password_confirm: event.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<span className="flex items-center justify-center gap-2">
|
||||||
|
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||||
|
Création...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'Créer mon compte'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</MainCard>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -6,6 +6,8 @@ import React, {
|
|||||||
} from 'react';
|
} from 'react';
|
||||||
import { timeApi } from '../api/time';
|
import { timeApi } from '../api/time';
|
||||||
import { useAuth } from '../context/useAuth';
|
import { useAuth } from '../context/useAuth';
|
||||||
|
import LoadingCard from '@/components/LoadingCard';
|
||||||
|
import MainCard from '@/components/MainCard';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@ -17,8 +19,8 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import {
|
import {
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Users,
|
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
|
X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
const DEFAULT_START_HOUR = 8;
|
const DEFAULT_START_HOUR = 8;
|
||||||
@ -78,6 +80,7 @@ const WeeklySummaryPage = ({ userId }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [initialLoading, setInitialLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
const [availableScheduleHeight, setAvailableScheduleHeight] =
|
const [availableScheduleHeight, setAvailableScheduleHeight] =
|
||||||
@ -344,6 +347,7 @@ const WeeklySummaryPage = ({ userId }) => {
|
|||||||
console.error(loadError);
|
console.error(loadError);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
setInitialLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -504,6 +508,7 @@ const WeeklySummaryPage = ({ userId }) => {
|
|||||||
resizeObserver?.disconnect();
|
resizeObserver?.disconnect();
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
|
initialLoading,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
showAllUsers,
|
showAllUsers,
|
||||||
@ -971,35 +976,33 @@ const WeeklySummaryPage = ({ userId }) => {
|
|||||||
|
|
||||||
/*
|
/*
|
||||||
* ------------------------------------------------------------
|
* ------------------------------------------------------------
|
||||||
* États
|
* État initial
|
||||||
* ------------------------------------------------------------
|
* ------------------------------------------------------------
|
||||||
*/
|
*/
|
||||||
|
|
||||||
if (loading) {
|
if (initialLoading) {
|
||||||
return (
|
return (
|
||||||
<Card className="page-layout page-state weekly-schedule flex h-full w-full">
|
<LoadingCard
|
||||||
<CardContent className="p-6">
|
icon={CalendarDays}
|
||||||
<div className="flex h-32 items-center justify-center">
|
title="Planning"
|
||||||
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-blue-500" />
|
message="Chargement du planning..."
|
||||||
|
/>
|
||||||
<span className="ml-3">
|
|
||||||
Chargement...
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ------------------------------------------------------------
|
||||||
|
* Erreur
|
||||||
|
* ------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<Card className="page-layout page-state weekly-schedule schedule-error flex h-full w-full">
|
<MainCard title="Erreur" icon={X}>
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<div className="text-center text-red-500">
|
{error}
|
||||||
<p>{error}</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</MainCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1173,54 +1176,38 @@ const WeeklySummaryPage = ({ userId }) => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<MainCard
|
||||||
className={`flex h-full min-h-0 w-full weekly-schedule page-layout ${
|
icon={CalendarDays}
|
||||||
isMobile
|
className=
|
||||||
? 'overflow-visible'
|
{isMobile
|
||||||
: 'overflow-hidden'
|
? 'overflow-visible'
|
||||||
}${
|
: 'overflow-hidden'
|
||||||
isAdmin
|
}
|
||||||
? ' multi-user-schedule'
|
title={
|
||||||
: ''
|
isAdmin ? (
|
||||||
}`}
|
<>
|
||||||
>
|
{showAllUsers
|
||||||
<CardHeader className="shrink-0 border-b">
|
? 'Planning de tous les utilisateurs'
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
: `Planning de ${
|
||||||
<div className="min-w-0">
|
selectedUser
|
||||||
<CardTitle className="flex items-center gap-2">
|
? getUserLabel(selectedUser)
|
||||||
{isAdmin ? (
|
: 'l’utilisateur'
|
||||||
<>
|
}`}
|
||||||
<Users className="h-5 w-5" />
|
</>
|
||||||
|
) : (
|
||||||
{showAllUsers
|
<>
|
||||||
? 'Récapitulatif de tous les utilisateurs'
|
Planning
|
||||||
: `Récapitulatif de ${
|
</>
|
||||||
selectedUser
|
)
|
||||||
? getUserLabel(
|
}
|
||||||
selectedUser,
|
headerContent={
|
||||||
)
|
<>
|
||||||
: 'l’utilisateur'
|
|
||||||
}`}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<CalendarDays className="h-5 w-5" />
|
|
||||||
|
|
||||||
Emploi du temps
|
|
||||||
hebdomadaire
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CardTitle>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<div className="flex items-center gap-2">
|
<>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() =>
|
onClick={() => navigateWeek(-1)}
|
||||||
navigateWeek(-1)
|
|
||||||
}
|
|
||||||
className="flex items-center gap-1"
|
className="flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="h-4 w-4" />
|
<ChevronLeft className="h-4 w-4" />
|
||||||
@ -1232,45 +1219,35 @@ const WeeklySummaryPage = ({ userId }) => {
|
|||||||
className="text-sm"
|
className="text-sm"
|
||||||
>
|
>
|
||||||
Du{' '}
|
Du{' '}
|
||||||
{weekDays[0].toLocaleDateString(
|
{weekDays[0].toLocaleDateString('fr-FR', {
|
||||||
'fr-FR',
|
day: '2-digit',
|
||||||
{
|
month: 'short',
|
||||||
day: '2-digit',
|
})}{' '}
|
||||||
month: 'short',
|
|
||||||
},
|
|
||||||
)}{' '}
|
|
||||||
au{' '}
|
au{' '}
|
||||||
{weekDays[6].toLocaleDateString(
|
{weekDays[6].toLocaleDateString('fr-FR', {
|
||||||
'fr-FR',
|
day: '2-digit',
|
||||||
{
|
month: 'short',
|
||||||
day: '2-digit',
|
})}
|
||||||
month: 'short',
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() =>
|
onClick={() => navigateWeek(1)}
|
||||||
navigateWeek(1)
|
|
||||||
}
|
|
||||||
className="flex items-center gap-1"
|
className="flex items-center gap-1"
|
||||||
>
|
>
|
||||||
Suivante
|
Suivante
|
||||||
<ChevronRight className="h-4 w-4" />
|
<ChevronRight className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<div className="flex items-center justify-between gap-2 pt-2">
|
<>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() =>
|
onClick={() => navigateDay(-1)}
|
||||||
navigateDay(-1)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<ChevronLeft className="h-4 w-4" />
|
<ChevronLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@ -1279,9 +1256,7 @@ const WeeklySummaryPage = ({ userId }) => {
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="text-sm"
|
className="text-sm"
|
||||||
>
|
>
|
||||||
{weekDays[
|
{weekDays[selectedDayIndex].toLocaleDateString(
|
||||||
selectedDayIndex
|
|
||||||
].toLocaleDateString(
|
|
||||||
'fr-FR',
|
'fr-FR',
|
||||||
{
|
{
|
||||||
weekday: 'long',
|
weekday: 'long',
|
||||||
@ -1294,17 +1269,15 @@ const WeeklySummaryPage = ({ userId }) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() =>
|
onClick={() => navigateDay(1)}
|
||||||
navigateDay(1)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<ChevronRight className="h-4 w-4" />
|
<ChevronRight className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
</CardHeader>
|
}
|
||||||
|
>
|
||||||
<CardContent className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
<CardContent className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||||
{/* Sélection utilisateur */}
|
{/* Sélection utilisateur */}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
@ -1969,7 +1942,7 @@ const WeeklySummaryPage = ({ userId }) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</MainCard>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user