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,36 +166,84 @@ const AdminUsersPage = () => {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="flex h-9 w-full min-w-0 items-center gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={generateInviteLink}
|
||||||
|
size="sm"
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
Inviter un utilisateur
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{inviteLink && (
|
||||||
|
<>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={inviteLink}
|
||||||
|
readOnly
|
||||||
|
className="h-9 w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={copyToClipboard}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-9 shrink-0"
|
||||||
|
>
|
||||||
|
{isCopied ? 'Copié !' : 'Copier'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="overflow-x-auto mb-6">
|
<div className="overflow-x-auto mb-6">
|
||||||
<Table>
|
<Table className="w-full">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Nom</TableHead>
|
<TableHead className="text-center">Nom</TableHead>
|
||||||
<TableHead>Couleur</TableHead>
|
<TableHead className="text-center">Couleur</TableHead>
|
||||||
<TableHead>Rôle</TableHead>
|
<TableHead className="text-center">Rôle</TableHead>
|
||||||
<TableHead>Inscrit le</TableHead>
|
<TableHead className="text-center">Inscrit le</TableHead>
|
||||||
<TableHead>Actions</TableHead>
|
<TableHead className="text-center">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
|
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{users.map((user) => (
|
{users.map((user) => (
|
||||||
<TableRow key={user.id}>
|
<TableRow key={user.id}>
|
||||||
<TableCell>{user.first_name} {user.last_name}</TableCell>
|
<TableCell className="text-center">
|
||||||
|
{user.first_name} {user.last_name}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center justify-center gap-2">
|
||||||
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: user.color || '#0000ff' }}></div>
|
<div
|
||||||
|
className="h-4 w-4 rounded-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor: user.color || '#0000ff'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<span>{user.color || 'Bleu'}</span>
|
<span>{user.color || 'Bleu'}</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
|
||||||
<Badge variant={user.is_admin ? "default" : "secondary"}>
|
<TableCell className="text-center">
|
||||||
|
<Badge variant={user.is_admin ? 'default' : 'secondary'}>
|
||||||
{user.is_admin ? 'Administrateur' : 'Utilisateur'}
|
{user.is_admin ? 'Administrateur' : 'Utilisateur'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{new Date(user.date_joined).toLocaleDateString('fr-FR')}</TableCell>
|
|
||||||
|
<TableCell className="text-center">
|
||||||
|
{new Date(user.date_joined).toLocaleDateString('fr-FR')}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex gap-2">
|
<div className="flex justify-center gap-2">
|
||||||
<Dialog open={editingUser === user.id} onOpenChange={(open) => {
|
<Dialog
|
||||||
|
open={editingUser === user.id}
|
||||||
|
onOpenChange={(open) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setEditingUser(null);
|
setEditingUser(null);
|
||||||
} else {
|
} else {
|
||||||
@ -215,88 +258,149 @@ const AdminUsersPage = () => {
|
|||||||
is_superuser: user.is_superuser,
|
is_superuser: user.is_superuser,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="outline" size="sm" onClick={() => setEditingUser(user.id)}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setEditingUser(user.id)}
|
||||||
|
>
|
||||||
Éditer
|
Éditer
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader className="border-b">
|
<DialogHeader className="border-b">
|
||||||
<DialogTitle>Modifier l'utilisateur</DialogTitle>
|
<DialogTitle>Modifier l'utilisateur</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor={`first-name-${user.id}`}>Prénom</Label>
|
<Label htmlFor={`first-name-${user.id}`}>
|
||||||
|
Prénom
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id={`first-name-${user.id}`}
|
id={`first-name-${user.id}`}
|
||||||
value={editForm.first_name}
|
value={editForm.first_name}
|
||||||
onChange={(event) => setEditForm({ ...editForm, first_name: event.target.value })}
|
onChange={(event) =>
|
||||||
|
setEditForm({
|
||||||
|
...editForm,
|
||||||
|
first_name: event.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor={`last-name-${user.id}`}>Nom</Label>
|
<Label htmlFor={`last-name-${user.id}`}>
|
||||||
|
Nom
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id={`last-name-${user.id}`}
|
id={`last-name-${user.id}`}
|
||||||
value={editForm.last_name}
|
value={editForm.last_name}
|
||||||
onChange={(event) => setEditForm({ ...editForm, last_name: event.target.value })}
|
onChange={(event) =>
|
||||||
|
setEditForm({
|
||||||
|
...editForm,
|
||||||
|
last_name: event.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor={`email-${user.id}`}>E-mail</Label>
|
<Label htmlFor={`email-${user.id}`}>E-mail</Label>
|
||||||
<Input
|
<Input
|
||||||
id={`email-${user.id}`}
|
id={`email-${user.id}`}
|
||||||
type="email"
|
type="email"
|
||||||
value={editForm.email}
|
value={editForm.email}
|
||||||
onChange={(event) => setEditForm({ ...editForm, email: event.target.value })}
|
onChange={(event) =>
|
||||||
|
setEditForm({
|
||||||
|
...editForm,
|
||||||
|
email: event.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor={`color-${user.id}`}>Couleur</Label>
|
<Label htmlFor={`color-${user.id}`}>Couleur</Label>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Input
|
<Input
|
||||||
id={`color-${user.id}`}
|
id={`color-${user.id}`}
|
||||||
type="color"
|
type="color"
|
||||||
value={/^#[0-9A-Fa-f]{6}$/.test(editForm.color) ? editForm.color : '#000000'}
|
value={
|
||||||
onChange={(event) => setEditForm({ ...editForm, color: event.target.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"
|
className="h-10 w-14 cursor-pointer p-1"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
aria-label="Valeur hexadécimale de la couleur"
|
aria-label="Valeur hexadécimale de la couleur"
|
||||||
value={editForm.color}
|
value={editForm.color}
|
||||||
pattern="^#[0-9A-Fa-f]{6}$"
|
pattern="^#[0-9A-Fa-f]{6}$"
|
||||||
onChange={(event) => setEditForm({ ...editForm, color: event.target.value })}
|
onChange={(event) =>
|
||||||
|
setEditForm({
|
||||||
|
...editForm,
|
||||||
|
color: event.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
className="max-w-36 font-mono uppercase"
|
className="max-w-36 font-mono uppercase"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="flex items-center gap-3 text-sm">
|
<label className="flex items-center gap-3 text-sm">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={editForm.is_active}
|
checked={editForm.is_active}
|
||||||
onCheckedChange={(checked) => setEditForm({ ...editForm, is_active: checked })}
|
onCheckedChange={(checked) =>
|
||||||
|
setEditForm({
|
||||||
|
...editForm,
|
||||||
|
is_active: checked,
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
Compte actif
|
Compte actif
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="flex items-center gap-3 text-sm">
|
<label className="flex items-center gap-3 text-sm">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={editForm.is_staff}
|
checked={editForm.is_staff}
|
||||||
onCheckedChange={(checked) => setEditForm({
|
onCheckedChange={(checked) =>
|
||||||
|
setEditForm({
|
||||||
...editForm,
|
...editForm,
|
||||||
is_staff: checked,
|
is_staff: checked,
|
||||||
is_superuser: checked ? editForm.is_superuser : false,
|
is_superuser: checked
|
||||||
})}
|
? editForm.is_superuser
|
||||||
|
: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
Administrateur
|
Administrateur
|
||||||
</label>
|
</label>
|
||||||
<p className="text-sm text-muted-foreground">Le mot de passe ne peut pas être modifié ici.</p>
|
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Le mot de passe ne peut pas être modifié ici.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button onClick={() => handleEditUser(user.id)}>
|
<Button onClick={() => handleEditUser(user.id)}>
|
||||||
Sauvegarder
|
Sauvegarder
|
||||||
</Button>
|
</Button>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{!user.is_admin && (
|
{!user.is_admin && (
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
title="Promouvoir l'utilisateur"
|
title="Promouvoir l'utilisateur"
|
||||||
@ -308,6 +412,7 @@ const AdminUsersPage = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</ConfirmDialog>
|
</ConfirmDialog>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
title="Supprimer l'utilisateur"
|
title="Supprimer l'utilisateur"
|
||||||
description="Êtes-vous sûr de vouloir supprimer cet utilisateur ?"
|
description="Êtes-vous sûr de vouloir supprimer cet utilisateur ?"
|
||||||
@ -324,36 +429,8 @@ const AdminUsersPage = () => {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
{inviteLink ? (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Button onClick={generateInviteLink}>
|
|
||||||
Inviter un utilisateur
|
|
||||||
</Button>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={inviteLink}
|
|
||||||
readOnly
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
onClick={copyToClipboard}
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
{isCopied ? 'Copié!' : 'Copier'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Button onClick={generateInviteLink}>
|
|
||||||
Inviter un utilisateur
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</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,21 +1,31 @@
|
|||||||
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 (
|
return (
|
||||||
<div className="flex h-full w-full">
|
<LoadingCard
|
||||||
<Card className="mx-auto h-full w-full">
|
icon={User}
|
||||||
<CardHeader className="border-b">
|
title="Profil"
|
||||||
<CardTitle>Profil Utilisateur</CardTitle>
|
message="Chargement du profil..."
|
||||||
</CardHeader>
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
<CardContent overflow-y-auto>
|
return (
|
||||||
{user && (
|
<MainCard title="Profil" icon={User}>
|
||||||
|
|
||||||
|
<CardContent className="overflow-y-auto">
|
||||||
<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>
|
||||||
@ -56,10 +66,8 @@ const HomePage = () => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</MainCard>
|
||||||
</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">
|
|
||||||
<CardHeader className="border-b">
|
|
||||||
<CardTitle>Connexion</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{error && (
|
{error && (
|
||||||
<Alert variant="destructive" className="mb-4">
|
<Alert
|
||||||
<AlertDescription>{error}</AlertDescription>
|
variant="destructive"
|
||||||
|
className="mb-4"
|
||||||
|
>
|
||||||
|
<AlertDescription>
|
||||||
|
{error}
|
||||||
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="username">Nom d'utilisateur:</Label>
|
<Label htmlFor="username">
|
||||||
|
Nom d'utilisateur:
|
||||||
|
</Label>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
id="username"
|
id="username"
|
||||||
type="text"
|
type="text"
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) =>
|
||||||
|
setUsername(e.target.value)
|
||||||
|
}
|
||||||
|
disabled={loading}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="password">Mot de passe:</Label>
|
<Label htmlFor="password">
|
||||||
|
Mot de passe:
|
||||||
|
</Label>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
className="current-password"
|
className="current-password"
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) =>
|
||||||
|
setPassword(e.target.value)
|
||||||
|
}
|
||||||
|
disabled={loading}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" className="w-full">
|
|
||||||
Se connecter
|
<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>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</MainCard>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
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,19 +25,30 @@ 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]
|
||||||
@ -40,111 +59,171 @@ const RegisterPage = () => {
|
|||||||
|| 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">
|
|
||||||
<CardHeader className="border-b">
|
|
||||||
<CardTitle>Lien invalide</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p>Un lien d’invitation est nécessaire pour créer un compte.</p>
|
Un lien d’invitation est nécessaire pour créer un compte.
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</MainCard>
|
||||||
</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">
|
|
||||||
<CardHeader className="border-b">
|
|
||||||
<CardTitle>Compte créé</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p>Votre compte est prêt. Vous pouvez maintenant vous connecter.</p>
|
Votre compte est prêt. Vous pouvez maintenant vous connecter.
|
||||||
<Button onClick={() => navigate('/login')} className="mt-4">
|
<Button
|
||||||
|
onClick={() => navigate('/login')}
|
||||||
|
className="mt-4"
|
||||||
|
>
|
||||||
Se connecter
|
Se connecter
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</MainCard>
|
||||||
</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">
|
|
||||||
<CardHeader className="border-b">
|
|
||||||
<CardTitle>Créer un compte</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{error && (
|
{error && (
|
||||||
<Alert variant="destructive" className="mb-4">
|
<Alert
|
||||||
<AlertDescription>{error}</AlertDescription>
|
variant="destructive"
|
||||||
|
className="mb-4"
|
||||||
|
>
|
||||||
|
<AlertDescription>
|
||||||
|
{error}
|
||||||
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="first-name">Prénom</Label>
|
<Label htmlFor="first-name">
|
||||||
|
Prénom
|
||||||
|
</Label>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
id="first-name"
|
id="first-name"
|
||||||
required
|
required
|
||||||
|
disabled={loading}
|
||||||
value={form.first_name}
|
value={form.first_name}
|
||||||
onChange={(event) => setForm({ ...form, first_name: event.target.value })}
|
onChange={(event) =>
|
||||||
|
setForm({
|
||||||
|
...form,
|
||||||
|
first_name: event.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="last-name">Nom</Label>
|
<Label htmlFor="last-name">
|
||||||
|
Nom
|
||||||
|
</Label>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
id="last-name"
|
id="last-name"
|
||||||
required
|
required
|
||||||
|
disabled={loading}
|
||||||
value={form.last_name}
|
value={form.last_name}
|
||||||
onChange={(event) => setForm({ ...form, last_name: event.target.value })}
|
onChange={(event) =>
|
||||||
|
setForm({
|
||||||
|
...form,
|
||||||
|
last_name: event.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="username">Nom d'utilisateur</Label>
|
<Label htmlFor="username">
|
||||||
|
Nom d'utilisateur
|
||||||
|
</Label>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
id="username"
|
id="username"
|
||||||
required
|
required
|
||||||
|
disabled={loading}
|
||||||
value={form.username}
|
value={form.username}
|
||||||
onChange={(event) => setForm({ ...form, username: event.target.value })}
|
onChange={(event) =>
|
||||||
|
setForm({
|
||||||
|
...form,
|
||||||
|
username: event.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="password">Mot de passe</Label>
|
<Label htmlFor="password">
|
||||||
|
Mot de passe
|
||||||
|
</Label>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
required
|
required
|
||||||
minLength="8"
|
minLength="8"
|
||||||
|
disabled={loading}
|
||||||
value={form.password}
|
value={form.password}
|
||||||
onChange={(event) => setForm({ ...form, password: event.target.value })}
|
onChange={(event) =>
|
||||||
|
setForm({
|
||||||
|
...form,
|
||||||
|
password: event.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="password-confirm">Confirmer le mot de passe</Label>
|
<Label htmlFor="password-confirm">
|
||||||
|
Confirmer le mot de passe
|
||||||
|
</Label>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
id="password-confirm"
|
id="password-confirm"
|
||||||
type="password"
|
type="password"
|
||||||
required
|
required
|
||||||
minLength="8"
|
minLength="8"
|
||||||
|
disabled={loading}
|
||||||
value={form.password_confirm}
|
value={form.password_confirm}
|
||||||
onChange={(event) => setForm({ ...form, password_confirm: event.target.value })}
|
onChange={(event) =>
|
||||||
|
setForm({
|
||||||
|
...form,
|
||||||
|
password_confirm: event.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" className="w-full">
|
|
||||||
Créer mon compte
|
<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>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</MainCard>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,8 @@ import { useEffect, useRef, useState } from 'react';
|
|||||||
import { BrowserQRCodeReader } from '@zxing/browser';
|
import { BrowserQRCodeReader } from '@zxing/browser';
|
||||||
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 {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@ -12,7 +13,6 @@ import {
|
|||||||
|
|
||||||
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 { Input } from '@/components/ui/input';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
@ -121,7 +121,18 @@ const formatDateLabel = (value) => {
|
|||||||
return 'Choisir une date';
|
return 'Choisir une date';
|
||||||
}
|
}
|
||||||
|
|
||||||
return date.toLocaleDateString(
|
const numericDate =
|
||||||
|
date.toLocaleDateString(
|
||||||
|
'fr-FR',
|
||||||
|
{
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const longDate =
|
||||||
|
date.toLocaleDateString(
|
||||||
'fr-FR',
|
'fr-FR',
|
||||||
{
|
{
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
@ -129,6 +140,18 @@ const formatDateLabel = (value) => {
|
|||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className="lg:hidden">
|
||||||
|
{longDate}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="hidden lg:inline">
|
||||||
|
{numericDate}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@ -334,8 +357,7 @@ const DatePicker = ({
|
|||||||
|
|
||||||
const TimePicker = ({
|
const TimePicker = ({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange
|
||||||
placeholder = 'Choisir une heure',
|
|
||||||
}) => {
|
}) => {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
@ -424,7 +446,6 @@ const TimePicker = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// PC : une molette = une unité
|
|
||||||
const handleWheel = (
|
const handleWheel = (
|
||||||
event,
|
event,
|
||||||
type,
|
type,
|
||||||
@ -442,7 +463,6 @@ const TimePicker = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// MOBILE : début du déplacement
|
|
||||||
const handleTouchStart = (event) => {
|
const handleTouchStart = (event) => {
|
||||||
const touch = event.touches[0];
|
const touch = event.touches[0];
|
||||||
|
|
||||||
@ -453,7 +473,6 @@ const TimePicker = ({
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// MOBILE : déplacement continu
|
|
||||||
const handleTouchMove = (
|
const handleTouchMove = (
|
||||||
event,
|
event,
|
||||||
type,
|
type,
|
||||||
@ -530,7 +549,19 @@ const TimePicker = ({
|
|||||||
: 'text-muted-foreground'
|
: 'text-muted-foreground'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{value || placeholder}
|
{value ? (
|
||||||
|
value
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="lg:hidden">
|
||||||
|
Choisir une heure
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="hidden lg:inline">
|
||||||
|
Choisir
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
|
|
||||||
@ -551,7 +582,6 @@ const TimePicker = ({
|
|||||||
<div className="relative flex h-[144px] gap-2 overflow-hidden">
|
<div className="relative flex h-[144px] gap-2 overflow-hidden">
|
||||||
<div className="pointer-events-none absolute inset-x-0 top-1/2 z-10 h-9 -translate-y-1/2 rounded-md border bg-muted/50" />
|
<div className="pointer-events-none absolute inset-x-0 top-1/2 z-10 h-9 -translate-y-1/2 rounded-md border bg-muted/50" />
|
||||||
|
|
||||||
{/* HEURES */}
|
|
||||||
<div
|
<div
|
||||||
className="flex-1 touch-none select-none"
|
className="flex-1 touch-none select-none"
|
||||||
onWheel={(event) =>
|
onWheel={(event) =>
|
||||||
@ -597,14 +627,12 @@ const TimePicker = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* : */}
|
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
<span className="font-bold">
|
<span className="font-bold">
|
||||||
:
|
:
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* MINUTES */}
|
|
||||||
<div
|
<div
|
||||||
className="flex-1 touch-none select-none"
|
className="flex-1 touch-none select-none"
|
||||||
onWheel={(event) =>
|
onWheel={(event) =>
|
||||||
@ -739,6 +767,11 @@ const TimeClockPage = () => {
|
|||||||
setLoading,
|
setLoading,
|
||||||
] = useState(true);
|
] = useState(true);
|
||||||
|
|
||||||
|
const [
|
||||||
|
initialLoading,
|
||||||
|
setInitialLoading,
|
||||||
|
] = useState(true);
|
||||||
|
|
||||||
const [
|
const [
|
||||||
isScannerDialogOpen,
|
isScannerDialogOpen,
|
||||||
setIsScannerDialogOpen,
|
setIsScannerDialogOpen,
|
||||||
@ -1345,6 +1378,7 @@ const TimeClockPage = () => {
|
|||||||
console.error(err);
|
console.error(err);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
setInitialLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -2446,6 +2480,21 @@ const TimeClockPage = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
/* =========================================================
|
||||||
|
INITIAL LOADING
|
||||||
|
========================================================= */
|
||||||
|
|
||||||
|
if (initialLoading) {
|
||||||
|
return (
|
||||||
|
<LoadingCard
|
||||||
|
icon={Clock}
|
||||||
|
title="Pointage"
|
||||||
|
message="Chargement du module de pointage..."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/* =========================================================
|
/* =========================================================
|
||||||
RENDER
|
RENDER
|
||||||
========================================================= */
|
========================================================= */
|
||||||
@ -2454,25 +2503,10 @@ const TimeClockPage = () => {
|
|||||||
getScanConflictConfig();
|
getScanConflictConfig();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="page-container page-layout flex h-full min-h-0 w-full flex-col overflow-hidden">
|
<MainCard title="Pointage" icon={Clock}>
|
||||||
|
|
||||||
<CardHeader className="shrink-0 border-b">
|
|
||||||
<CardTitle>
|
|
||||||
Pointage
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
|
|
||||||
{/* IMPORTANT :
|
|
||||||
flex + flex-col + min-h-0 permettent à la grille
|
|
||||||
de récupérer la hauteur restante sur desktop.
|
|
||||||
Mobile garde le scroll global. */}
|
|
||||||
<CardContent className="flex min-h-0 flex-1 flex-col overflow-y-auto lg:overflow-hidden">
|
<CardContent className="flex min-h-0 flex-1 flex-col overflow-y-auto lg:overflow-hidden">
|
||||||
|
|
||||||
{/* =========================
|
|
||||||
ALERTES
|
|
||||||
========================== */}
|
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<Alert
|
<Alert
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
@ -2584,14 +2618,13 @@ const TimeClockPage = () => {
|
|||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div>
|
<div className="grid grid-cols-[140px_minmax(0,1fr)] items-center gap-3">
|
||||||
<Label>
|
<Label>
|
||||||
{
|
{
|
||||||
scanConflictConfig.timeLabel
|
scanConflictConfig.timeLabel
|
||||||
}
|
}
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
<div className="mt-2">
|
|
||||||
<TimePicker
|
<TimePicker
|
||||||
value={
|
value={
|
||||||
conflictTime
|
conflictTime
|
||||||
@ -2601,7 +2634,6 @@ const TimeClockPage = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
|
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
|
||||||
<Button
|
<Button
|
||||||
@ -2709,8 +2741,8 @@ const TimeClockPage = () => {
|
|||||||
====================================================== */}
|
====================================================== */}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`grid w-full min-h-0 grid-cols-1 gap-6 lg:flex-1 ${
|
className={`grid w-full min-h-0 grid-cols-1 gap-6 ${
|
||||||
isAdmin ? 'lg:grid-cols-2' : ''
|
isAdmin ? 'lg:grid-cols-2 lg:flex-1' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|
||||||
@ -2722,7 +2754,6 @@ const TimeClockPage = () => {
|
|||||||
<div className="flex min-w-0 flex-col lg:h-full lg:min-h-0">
|
<div className="flex min-w-0 flex-col lg:h-full lg:min-h-0">
|
||||||
<Card className="lg:h-full">
|
<Card className="lg:h-full">
|
||||||
|
|
||||||
|
|
||||||
<CardHeader className="border-b">
|
<CardHeader className="border-b">
|
||||||
<CardTitle>
|
<CardTitle>
|
||||||
Gestion des pointages
|
Gestion des pointages
|
||||||
@ -2730,15 +2761,18 @@ const TimeClockPage = () => {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
|
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-4">
|
||||||
|
|
||||||
{/* UTILISATEUR */}
|
<Card className="shadow-none">
|
||||||
|
|
||||||
<div>
|
<CardHeader className="border-b">
|
||||||
|
<CardTitle className="text-base">
|
||||||
<Label htmlFor="selected-user">
|
|
||||||
Voir les pointages de :
|
Voir les pointages de :
|
||||||
</Label>
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
|
||||||
<select
|
<select
|
||||||
id="selected-user"
|
id="selected-user"
|
||||||
@ -2762,7 +2796,7 @@ const TimeClockPage = () => {
|
|||||||
|
|
||||||
setError('');
|
setError('');
|
||||||
}}
|
}}
|
||||||
className="mt-2 flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
>
|
>
|
||||||
|
|
||||||
{users.length ===
|
{users.length ===
|
||||||
@ -2793,7 +2827,7 @@ const TimeClockPage = () => {
|
|||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
|
||||||
<label className="mt-3 flex h-10 cursor-pointer items-center gap-2 rounded-md border px-3 text-sm">
|
<label className="flex h-10 cursor-pointer items-center gap-2 rounded-md border px-3 text-sm">
|
||||||
|
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@ -2823,32 +2857,29 @@ const TimeClockPage = () => {
|
|||||||
|
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
</div>
|
</CardContent>
|
||||||
|
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* SEPARATION */}
|
{(!showAllUsers || editingEntry) && (
|
||||||
|
<Card className="shadow-none">
|
||||||
|
|
||||||
{!showAllUsers && (
|
<CardHeader className="border-b">
|
||||||
<div className="border-t pt-6">
|
<CardTitle className="text-base">
|
||||||
|
|
||||||
<div className="mb-4">
|
|
||||||
<h3 className="text-sm font-medium">
|
|
||||||
{editingEntry
|
{editingEntry
|
||||||
? 'Modifier le pointage'
|
? `Modifier un pointage de ${getUserLabel(
|
||||||
: `Ajouter un pointage pour${
|
users.find((u) => u.id === editingEntry.user.id)?.first_name
|
||||||
selectedUser
|
|
||||||
? ` ${getUserLabel(
|
|
||||||
selectedUser,
|
|
||||||
)}`
|
)}`
|
||||||
|
: `Ajouter un pointage à ${
|
||||||
|
selectedUser
|
||||||
|
? ` ${getUserLabel(selectedUser)}`
|
||||||
: ''
|
: ''
|
||||||
}`}
|
}`}
|
||||||
</h3>
|
</CardTitle>
|
||||||
</div>
|
</CardHeader>
|
||||||
|
|
||||||
|
|
||||||
{/* =============================
|
<CardContent>
|
||||||
EDITION
|
|
||||||
============================== */}
|
|
||||||
|
|
||||||
{editingEntry ? (
|
{editingEntry ? (
|
||||||
<form
|
<form
|
||||||
@ -2857,17 +2888,18 @@ const TimeClockPage = () => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
||||||
<div className="mb-4 grid grid-cols-1 gap-4">
|
<div className="mb-4 grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
|
||||||
{/* DATE D'ENTRÉE */}
|
{/* DATE D'ENTRÉE */}
|
||||||
|
|
||||||
<div>
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
<Label>
|
<Label className="w-[115px] shrink-0">
|
||||||
Date d'entrée :
|
Date d'entrée :
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
{editingEntry.isGrouped ? (
|
{editingEntry.isGrouped ? (
|
||||||
<div className="mt-2 flex h-10 items-center rounded-md border bg-muted/50 px-3 text-sm">
|
<div className="flex h-10 items-center rounded-md border bg-muted/50 px-3 text-sm">
|
||||||
{formatDateLabel(
|
{formatDateLabel(
|
||||||
editForm.date,
|
editForm.date,
|
||||||
)}
|
)}
|
||||||
@ -2888,8 +2920,7 @@ const TimeClockPage = () => {
|
|||||||
|
|
||||||
end_date:
|
end_date:
|
||||||
editForm.end_date &&
|
editForm.end_date &&
|
||||||
editForm.end_date <
|
editForm.end_date < value
|
||||||
value
|
|
||||||
? value
|
? value
|
||||||
: editForm.end_date,
|
: editForm.end_date,
|
||||||
},
|
},
|
||||||
@ -2898,17 +2929,47 @@ const TimeClockPage = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{/* HEURE D'ENTRÉE */}
|
||||||
|
|
||||||
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
|
<Label className="w-[115px] shrink-0">
|
||||||
|
Heure d'entrée :
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<TimePicker
|
||||||
|
value={
|
||||||
|
editForm.start_time
|
||||||
|
}
|
||||||
|
onChange={(
|
||||||
|
value,
|
||||||
|
) =>
|
||||||
|
setEditForm(
|
||||||
|
{
|
||||||
|
...editForm,
|
||||||
|
start_time:
|
||||||
|
value,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{/* DATE DE SORTIE */}
|
{/* DATE DE SORTIE */}
|
||||||
|
|
||||||
<div>
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
<Label>
|
<Label className="w-[115px] shrink-0">
|
||||||
Date de sortie :
|
Date de sortie :
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
{editingEntry.isGrouped ? (
|
{editingEntry.isGrouped ? (
|
||||||
<div className="mt-2 flex h-10 items-center rounded-md border bg-muted/50 px-3 text-sm">
|
<div className="flex h-10 items-center rounded-md border bg-muted/50 px-3 text-sm">
|
||||||
{formatDateLabel(
|
{formatDateLabel(
|
||||||
editForm.end_date,
|
editForm.end_date,
|
||||||
)}
|
)}
|
||||||
@ -2935,41 +2996,17 @@ const TimeClockPage = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{/* HEURE D'ENTRÉE */}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label>
|
|
||||||
Heure d'entrée :
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<TimePicker
|
|
||||||
value={
|
|
||||||
editForm.start_time
|
|
||||||
}
|
|
||||||
onChange={(
|
|
||||||
value,
|
|
||||||
) =>
|
|
||||||
setEditForm(
|
|
||||||
{
|
|
||||||
...editForm,
|
|
||||||
start_time:
|
|
||||||
value,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{/* HEURE DE SORTIE */}
|
{/* HEURE DE SORTIE */}
|
||||||
|
|
||||||
<div>
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
<Label>
|
<Label className="w-[115px] shrink-0">
|
||||||
Heure de sortie :
|
Heure de sortie :
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
<TimePicker
|
<TimePicker
|
||||||
value={
|
value={
|
||||||
editForm.end_time
|
editForm.end_time
|
||||||
@ -2987,6 +3024,7 @@ const TimeClockPage = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -3024,25 +3062,22 @@ const TimeClockPage = () => {
|
|||||||
</form>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
|
|
||||||
/* =============================
|
|
||||||
AJOUT
|
|
||||||
============================== */
|
|
||||||
|
|
||||||
<form
|
<form
|
||||||
onSubmit={
|
onSubmit={
|
||||||
handleAddEntry
|
handleAddEntry
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
||||||
<div className="mb-4 grid grid-cols-1 gap-4">
|
<div className="mb-4 grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
|
||||||
{/* DATE D'ENTRÉE */}
|
{/* DATE D'ENTRÉE */}
|
||||||
|
|
||||||
<div>
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
<Label>
|
<Label className="w-[115px] shrink-0">
|
||||||
Date d'entrée :
|
Date d'entrée :
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
<DatePicker
|
<DatePicker
|
||||||
value={
|
value={
|
||||||
newEntry.date
|
newEntry.date
|
||||||
@ -3058,8 +3093,7 @@ const TimeClockPage = () => {
|
|||||||
|
|
||||||
end_date:
|
end_date:
|
||||||
newEntry.end_date &&
|
newEntry.end_date &&
|
||||||
newEntry.end_date <
|
newEntry.end_date < value
|
||||||
value
|
|
||||||
? value
|
? value
|
||||||
: newEntry.end_date,
|
: newEntry.end_date,
|
||||||
},
|
},
|
||||||
@ -3067,15 +3101,45 @@ const TimeClockPage = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{/* HEURE D'ENTRÉE */}
|
||||||
|
|
||||||
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
|
<Label className="w-[115px] shrink-0">
|
||||||
|
Heure d'entrée :
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<TimePicker
|
||||||
|
value={
|
||||||
|
newEntry.start_time
|
||||||
|
}
|
||||||
|
onChange={(
|
||||||
|
value,
|
||||||
|
) =>
|
||||||
|
setNewEntry(
|
||||||
|
{
|
||||||
|
...newEntry,
|
||||||
|
start_time:
|
||||||
|
value,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{/* DATE DE SORTIE */}
|
{/* DATE DE SORTIE */}
|
||||||
|
|
||||||
<div>
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
<Label>
|
<Label className="w-[115px] shrink-0">
|
||||||
Date de sortie :
|
Date de sortie :
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
<DatePicker
|
<DatePicker
|
||||||
value={
|
value={
|
||||||
newEntry.end_date
|
newEntry.end_date
|
||||||
@ -3096,41 +3160,17 @@ const TimeClockPage = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{/* HEURE D'ENTRÉE */}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label>
|
|
||||||
Heure d'entrée :
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<TimePicker
|
|
||||||
value={
|
|
||||||
newEntry.start_time
|
|
||||||
}
|
|
||||||
onChange={(
|
|
||||||
value,
|
|
||||||
) =>
|
|
||||||
setNewEntry(
|
|
||||||
{
|
|
||||||
...newEntry,
|
|
||||||
start_time:
|
|
||||||
value,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{/* HEURE DE SORTIE */}
|
{/* HEURE DE SORTIE */}
|
||||||
|
|
||||||
<div>
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
<Label>
|
<Label className="w-[115px] shrink-0">
|
||||||
Heure de sortie :
|
Heure de sortie :
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
<TimePicker
|
<TimePicker
|
||||||
value={
|
value={
|
||||||
newEntry.end_time
|
newEntry.end_time
|
||||||
@ -3148,6 +3188,7 @@ const TimeClockPage = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -3162,9 +3203,12 @@ const TimeClockPage = () => {
|
|||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</CardContent>
|
||||||
|
|
||||||
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
</Card>
|
</Card>
|
||||||
@ -3191,18 +3235,9 @@ const TimeClockPage = () => {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
|
|
||||||
{/* DESKTOP : SEUL CETTE ZONE SCROLLE
|
|
||||||
MOBILE : LE SCROLL EST GÉRÉ PAR LE CARD
|
|
||||||
PRINCIPAL */}
|
|
||||||
|
|
||||||
<CardContent className="min-h-0 flex-1 overflow-visible lg:overflow-y-auto">
|
<CardContent className="min-h-0 flex-1 overflow-visible lg:overflow-y-auto">
|
||||||
|
|
||||||
{loading ? (
|
{displayedTimeEntries.length ===
|
||||||
<p>
|
|
||||||
Chargement des
|
|
||||||
pointages...
|
|
||||||
</p>
|
|
||||||
) : displayedTimeEntries.length ===
|
|
||||||
0 ? (
|
0 ? (
|
||||||
<p>
|
<p>
|
||||||
Aucun pointage
|
Aucun pointage
|
||||||
@ -3281,8 +3316,6 @@ const TimeClockPage = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
{/* DATE */}
|
|
||||||
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{formatEntryDate(
|
{formatEntryDate(
|
||||||
entry,
|
entry,
|
||||||
@ -3290,8 +3323,6 @@ const TimeClockPage = () => {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
|
|
||||||
{/* ENTRÉE */}
|
|
||||||
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{formatTime(
|
{formatTime(
|
||||||
entry.start_time,
|
entry.start_time,
|
||||||
@ -3299,8 +3330,6 @@ const TimeClockPage = () => {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
|
|
||||||
{/* SORTIE */}
|
|
||||||
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{formatTime(
|
{formatTime(
|
||||||
entry.end_time,
|
entry.end_time,
|
||||||
@ -3308,8 +3337,6 @@ const TimeClockPage = () => {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
|
|
||||||
{/* DURÉE */}
|
|
||||||
|
|
||||||
<TableCell className="text-center">
|
<TableCell className="text-center">
|
||||||
{duration !==
|
{duration !==
|
||||||
null &&
|
null &&
|
||||||
@ -3328,8 +3355,6 @@ const TimeClockPage = () => {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
|
|
||||||
{/* STATUT */}
|
|
||||||
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{entry.start_time &&
|
{entry.start_time &&
|
||||||
entry.end_time ? (
|
entry.end_time ? (
|
||||||
@ -3348,8 +3373,6 @@ const TimeClockPage = () => {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
|
|
||||||
{/* ACTIONS */}
|
|
||||||
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<TableCell>
|
<TableCell>
|
||||||
|
|
||||||
@ -3422,7 +3445,7 @@ const TimeClockPage = () => {
|
|||||||
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
</Card>
|
</MainCard>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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=
|
||||||
|
{isMobile
|
||||||
? 'overflow-visible'
|
? 'overflow-visible'
|
||||||
: 'overflow-hidden'
|
: 'overflow-hidden'
|
||||||
}${
|
}
|
||||||
isAdmin
|
title={
|
||||||
? ' multi-user-schedule'
|
isAdmin ? (
|
||||||
: ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<CardHeader className="shrink-0 border-b">
|
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
{isAdmin ? (
|
|
||||||
<>
|
<>
|
||||||
<Users className="h-5 w-5" />
|
|
||||||
|
|
||||||
{showAllUsers
|
{showAllUsers
|
||||||
? 'Récapitulatif de tous les utilisateurs'
|
? 'Planning de tous les utilisateurs'
|
||||||
: `Récapitulatif de ${
|
: `Planning de ${
|
||||||
selectedUser
|
selectedUser
|
||||||
? getUserLabel(
|
? getUserLabel(selectedUser)
|
||||||
selectedUser,
|
|
||||||
)
|
|
||||||
: 'l’utilisateur'
|
: 'l’utilisateur'
|
||||||
}`}
|
}`}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<CalendarDays className="h-5 w-5" />
|
Planning
|
||||||
|
|
||||||
Emploi du temps
|
|
||||||
hebdomadaire
|
|
||||||
</>
|
</>
|
||||||
)}
|
)
|
||||||
</CardTitle>
|
}
|
||||||
</div>
|
headerContent={
|
||||||
|
<>
|
||||||
{!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',
|
day: '2-digit',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
},
|
})}{' '}
|
||||||
)}{' '}
|
|
||||||
au{' '}
|
au{' '}
|
||||||
{weekDays[6].toLocaleDateString(
|
{weekDays[6].toLocaleDateString('fr-FR', {
|
||||||
'fr-FR',
|
|
||||||
{
|
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
month: 'short',
|
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