This commit is contained in:
Louis Mazin 2026-09-21 19:23:30 +02:00
parent 876ec75a4e
commit 5298d69e1f
23 changed files with 1862 additions and 962 deletions

View File

@ -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

View File

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

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

View File

30
backend/events/models.py Normal file
View 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

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

View File

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

View File

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

View File

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

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

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

View File

@ -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>
} }

View File

@ -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}

View File

@ -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,213 +149,288 @@ 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">
<AlertDescription>{error}</AlertDescription> <AlertDescription>{error}</AlertDescription>
</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>
<div className="overflow-x-auto mb-6"> {inviteLink && (
<Table> <>
<TableHeader> <div className="min-w-0 flex-1">
<TableRow>
<TableHead>Nom</TableHead>
<TableHead>Couleur</TableHead>
<TableHead>Rôle</TableHead>
<TableHead>Inscrit le</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => (
<TableRow key={user.id}>
<TableCell>{user.first_name} {user.last_name}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: user.color || '#0000ff' }}></div>
<span>{user.color || 'Bleu'}</span>
</div>
</TableCell>
<TableCell>
<Badge variant={user.is_admin ? "default" : "secondary"}>
{user.is_admin ? 'Administrateur' : 'Utilisateur'}
</Badge>
</TableCell>
<TableCell>{new Date(user.date_joined).toLocaleDateString('fr-FR')}</TableCell>
<TableCell>
<div className="flex gap-2">
<Dialog open={editingUser === user.id} onOpenChange={(open) => {
if (!open) {
setEditingUser(null);
} else {
setEditingUser(user.id);
setEditForm({
first_name: user.first_name || '',
last_name: user.last_name || '',
email: user.email || '',
color: user.color || '#0000ff',
is_active: user.is_active,
is_staff: user.is_staff || user.is_admin,
is_superuser: user.is_superuser,
});
}
}}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" onClick={() => setEditingUser(user.id)}>
Éditer
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader className="border-b">
<DialogTitle>Modifier l'utilisateur</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor={`first-name-${user.id}`}>Prénom</Label>
<Input
id={`first-name-${user.id}`}
value={editForm.first_name}
onChange={(event) => setEditForm({ ...editForm, first_name: event.target.value })}
/>
</div>
<div className="grid gap-2">
<Label htmlFor={`last-name-${user.id}`}>Nom</Label>
<Input
id={`last-name-${user.id}`}
value={editForm.last_name}
onChange={(event) => setEditForm({ ...editForm, last_name: event.target.value })}
/>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor={`email-${user.id}`}>E-mail</Label>
<Input
id={`email-${user.id}`}
type="email"
value={editForm.email}
onChange={(event) => setEditForm({ ...editForm, email: event.target.value })}
/>
</div>
<div className="grid gap-2">
<Label htmlFor={`color-${user.id}`}>Couleur</Label>
<div className="flex items-center gap-3">
<Input
id={`color-${user.id}`}
type="color"
value={/^#[0-9A-Fa-f]{6}$/.test(editForm.color) ? editForm.color : '#000000'}
onChange={(event) => setEditForm({ ...editForm, color: event.target.value })}
className="h-10 w-14 cursor-pointer p-1"
/>
<Input
aria-label="Valeur hexadécimale de la couleur"
value={editForm.color}
pattern="^#[0-9A-Fa-f]{6}$"
onChange={(event) => setEditForm({ ...editForm, color: event.target.value })}
className="max-w-36 font-mono uppercase"
/>
</div>
</div>
<label className="flex items-center gap-3 text-sm">
<Checkbox
checked={editForm.is_active}
onCheckedChange={(checked) => setEditForm({ ...editForm, is_active: checked })}
/>
Compte actif
</label>
<label className="flex items-center gap-3 text-sm">
<Checkbox
checked={editForm.is_staff}
onCheckedChange={(checked) => setEditForm({
...editForm,
is_staff: checked,
is_superuser: checked ? editForm.is_superuser : false,
})}
/>
Administrateur
</label>
<p className="text-sm text-muted-foreground">Le mot de passe ne peut pas être modifié ici.</p>
</div>
<Button onClick={() => handleEditUser(user.id)}>
Sauvegarder
</Button>
</DialogContent>
</Dialog>
{!user.is_admin && (
<ConfirmDialog
title="Promouvoir l'utilisateur"
description="Êtes-vous sûr de vouloir promouvoir cet utilisateur ?"
onConfirm={() => handleMakeAdmin(user.id)}
>
<Button variant="outline" size="sm">
Promouvoir
</Button>
</ConfirmDialog>
)}
<ConfirmDialog
title="Supprimer l'utilisateur"
description="Êtes-vous sûr de vouloir supprimer cet utilisateur ?"
onConfirm={() => handleDeleteUser(user.id)}
>
<Button variant="destructive" size="sm">
Supprimer
</Button>
</ConfirmDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="flex flex-col gap-4">
{inviteLink ? (
<div className="flex flex-col gap-2">
<Button onClick={generateInviteLink}>
Inviter un utilisateur
</Button>
<div className="flex gap-2">
<Input <Input
type="text" type="text"
value={inviteLink} value={inviteLink}
readOnly readOnly
className="flex-1" className="h-9 w-full"
/> />
<Button </div>
onClick={copyToClipboard}
variant="outline" <Button
> onClick={copyToClipboard}
{isCopied ? 'Copié!' : 'Copier'} variant="outline"
</Button> size="sm"
</div> className="h-9 shrink-0"
</div> >
) : ( {isCopied ? 'Copié !' : 'Copier'}
<Button onClick={generateInviteLink}> </Button>
Inviter un utilisateur </>
</Button>
)} )}
</div> </div>
<div className="overflow-x-auto mb-6">
<Table className="w-full">
<TableHeader>
<TableRow>
<TableHead className="text-center">Nom</TableHead>
<TableHead className="text-center">Couleur</TableHead>
<TableHead className="text-center">Rôle</TableHead>
<TableHead className="text-center">Inscrit le</TableHead>
<TableHead className="text-center">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => (
<TableRow key={user.id}>
<TableCell className="text-center">
{user.first_name} {user.last_name}
</TableCell>
<TableCell>
<div className="flex items-center justify-center gap-2">
<div
className="h-4 w-4 rounded-full"
style={{
backgroundColor: user.color || '#0000ff'
}}
/>
<span>{user.color || 'Bleu'}</span>
</div>
</TableCell>
<TableCell className="text-center">
<Badge variant={user.is_admin ? 'default' : 'secondary'}>
{user.is_admin ? 'Administrateur' : 'Utilisateur'}
</Badge>
</TableCell>
<TableCell className="text-center">
{new Date(user.date_joined).toLocaleDateString('fr-FR')}
</TableCell>
<TableCell>
<div className="flex justify-center gap-2">
<Dialog
open={editingUser === user.id}
onOpenChange={(open) => {
if (!open) {
setEditingUser(null);
} else {
setEditingUser(user.id);
setEditForm({
first_name: user.first_name || '',
last_name: user.last_name || '',
email: user.email || '',
color: user.color || '#0000ff',
is_active: user.is_active,
is_staff: user.is_staff || user.is_admin,
is_superuser: user.is_superuser,
});
}
}}
>
<DialogTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={() => setEditingUser(user.id)}
>
Éditer
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader className="border-b">
<DialogTitle>Modifier l'utilisateur</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor={`first-name-${user.id}`}>
Prénom
</Label>
<Input
id={`first-name-${user.id}`}
value={editForm.first_name}
onChange={(event) =>
setEditForm({
...editForm,
first_name: event.target.value,
})
}
/>
</div>
<div className="grid gap-2">
<Label htmlFor={`last-name-${user.id}`}>
Nom
</Label>
<Input
id={`last-name-${user.id}`}
value={editForm.last_name}
onChange={(event) =>
setEditForm({
...editForm,
last_name: event.target.value,
})
}
/>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor={`email-${user.id}`}>E-mail</Label>
<Input
id={`email-${user.id}`}
type="email"
value={editForm.email}
onChange={(event) =>
setEditForm({
...editForm,
email: event.target.value,
})
}
/>
</div>
<div className="grid gap-2">
<Label htmlFor={`color-${user.id}`}>Couleur</Label>
<div className="flex items-center gap-3">
<Input
id={`color-${user.id}`}
type="color"
value={
/^#[0-9A-Fa-f]{6}$/.test(editForm.color)
? editForm.color
: '#000000'
}
onChange={(event) =>
setEditForm({
...editForm,
color: event.target.value,
})
}
className="h-10 w-14 cursor-pointer p-1"
/>
<Input
aria-label="Valeur hexadécimale de la couleur"
value={editForm.color}
pattern="^#[0-9A-Fa-f]{6}$"
onChange={(event) =>
setEditForm({
...editForm,
color: event.target.value,
})
}
className="max-w-36 font-mono uppercase"
/>
</div>
</div>
<label className="flex items-center gap-3 text-sm">
<Checkbox
checked={editForm.is_active}
onCheckedChange={(checked) =>
setEditForm({
...editForm,
is_active: checked,
})
}
/>
Compte actif
</label>
<label className="flex items-center gap-3 text-sm">
<Checkbox
checked={editForm.is_staff}
onCheckedChange={(checked) =>
setEditForm({
...editForm,
is_staff: checked,
is_superuser: checked
? editForm.is_superuser
: false,
})
}
/>
Administrateur
</label>
<p className="text-sm text-muted-foreground">
Le mot de passe ne peut pas être modifié ici.
</p>
</div>
<Button onClick={() => handleEditUser(user.id)}>
Sauvegarder
</Button>
</DialogContent>
</Dialog>
{!user.is_admin && (
<ConfirmDialog
title="Promouvoir l'utilisateur"
description="Êtes-vous sûr de vouloir promouvoir cet utilisateur ?"
onConfirm={() => handleMakeAdmin(user.id)}
>
<Button variant="outline" size="sm">
Promouvoir
</Button>
</ConfirmDialog>
)}
<ConfirmDialog
title="Supprimer l'utilisateur"
description="Êtes-vous sûr de vouloir supprimer cet utilisateur ?"
onConfirm={() => handleDeleteUser(user.id)}
>
<Button variant="destructive" size="sm">
Supprimer
</Button>
</ConfirmDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent> </CardContent>
</Card> </MainCard>
); );
}; };

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

View File

@ -1,65 +1,73 @@
import React from 'react'; import React from 'react';
import LoadingCard from '@/components/LoadingCard';
import MainCard from '@/components/MainCard';
import { useAuth } from '../context/useAuth'; import { useAuth } from '../context/useAuth';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import {
CardContent,
} from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { User } from 'lucide-react';
const HomePage = () => { const HomePage = () => {
const { user } = useAuth(); const { user } = useAuth();
if (!user) {
return (
<LoadingCard
icon={User}
title="Profil"
message="Chargement du profil..."
/>
);
}
return ( return (
<div className="flex h-full w-full"> <MainCard title="Profil" icon={User}>
<Card className="mx-auto h-full w-full">
<CardHeader className="border-b">
<CardTitle>Profil Utilisateur</CardTitle>
</CardHeader>
<CardContent overflow-y-auto> <CardContent className="overflow-y-auto">
{user && ( <div className="space-y-4">
<div className="space-y-4"> <div className="flex items-center justify-between">
<div className="flex items-center justify-between"> <span className="font-medium">Prénom:</span>
<span className="font-medium">Prénom:</span> <span>{user.first_name}</span>
<span>{user.first_name}</span> </div>
</div>
<Separator /> <Separator />
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="font-medium">Nom:</span> <span className="font-medium">Nom:</span>
<span>{user.last_name}</span> <span>{user.last_name}</span>
</div> </div>
<Separator /> <Separator />
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="font-medium">Admin:</span> <span className="font-medium">Admin:</span>
<Badge variant={user.is_admin ? 'default' : 'secondary'}> <Badge variant={user.is_admin ? 'default' : 'secondary'}>
{user.is_admin ? 'Oui' : 'Non'} {user.is_admin ? 'Oui' : 'Non'}
</Badge> </Badge>
</div> </div>
<Separator /> <Separator />
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="font-medium">Nom d'utilisateur:</span> <span className="font-medium">Nom d'utilisateur:</span>
<span>{user.username}</span> <span>{user.username}</span>
</div> </div>
<Separator /> <Separator />
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="font-medium">Date d'inscription:</span> <span className="font-medium">Date d'inscription:</span>
<span> <span>
{user.date_joined {user.date_joined
? new Date(user.date_joined).toLocaleDateString('fr-FR') ? new Date(user.date_joined).toLocaleDateString('fr-FR')
: 'Non spécifié'} : 'Non spécifié'}
</span> </span>
</div> </div>
</div> </div>
)} </CardContent>
</CardContent> </MainCard>
</Card>
</div>
); );
}; };

View File

@ -1,79 +1,125 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom'; import { useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../context/useAuth'; import { useAuth } from '../context/useAuth';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import MainCard from '../components/MainCard';
import { CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert'; import {
Alert,
AlertDescription,
} from '@/components/ui/alert';
import {KeyRound} from 'lucide-react';
const LoginPage = () => { const LoginPage = () => {
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const { login } = useAuth(); const { login } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const from = location.state?.from?.pathname || "/"; const from =
location.state?.from?.pathname || '/';
const handleSubmit = async (e) => { const handleSubmit = async (e) => {
e.preventDefault(); e.preventDefault();
if (loading) {
return;
}
setError(''); setError('');
setLoading(true);
try { try {
await login(username, password); await login(username, password);
navigate(from, { replace: true }); navigate(from, { replace: true });
} catch (err) { } catch (err) {
setError('Nom d\'utilisateur ou mot de passe incorrect'); setError(
'Nom d\'utilisateur ou mot de passe incorrect',
);
console.error(err); console.error(err);
} finally {
setLoading(false);
} }
}; };
return ( return (
<div className="auth-page page-layout flex h-full w-full"> <MainCard title="Connexion" icon={KeyRound}>
<Card className="flex h-full min-h-0 w-full overflow-y-auto"> <CardContent>
<CardHeader className="border-b"> {error && (
<CardTitle>Connexion</CardTitle> <Alert
</CardHeader> variant="destructive"
<CardContent> className="mb-4"
{error && ( >
<Alert variant="destructive" className="mb-4"> <AlertDescription>
<AlertDescription>{error}</AlertDescription> {error}
</Alert> </AlertDescription>
)} </Alert>
<form onSubmit={handleSubmit} className="space-y-4"> )}
<div className="space-y-2">
<Label htmlFor="username">Nom d'utilisateur:</Label> <form
<Input onSubmit={handleSubmit}
id="username" className="space-y-4"
type="text" >
autoComplete="username" <div className="space-y-2">
value={username} <Label htmlFor="username">
onChange={(e) => setUsername(e.target.value)} Nom d'utilisateur:
required </Label>
/>
</div> <Input
<div className="space-y-2"> id="username"
<Label htmlFor="password">Mot de passe:</Label> type="text"
<Input autoComplete="username"
className="current-password" value={username}
id="password" onChange={(e) =>
type="password" setUsername(e.target.value)
autoComplete="current-password" }
value={password} disabled={loading}
onChange={(e) => setPassword(e.target.value)} required
required />
/> </div>
</div>
<Button type="submit" className="w-full"> <div className="space-y-2">
Se connecter <Label htmlFor="password">
</Button> Mot de passe:
</form> </Label>
</CardContent>
</Card> <Input
</div> className="current-password"
id="password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) =>
setPassword(e.target.value)
}
disabled={loading}
required
/>
</div>
<Button
type="submit"
className="w-full"
disabled={loading}
>
{loading ? (
<span className="flex items-center justify-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
Connexion...
</span>
) : (
'Se connecter'
)}
</Button>
</form>
</CardContent>
</MainCard>
); );
}; };
export default LoginPage; export default LoginPage;

View File

@ -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,135 +25,206 @@ const RegisterPage = () => {
password: '', password: '',
password_confirm: '', password_confirm: '',
}); });
const [error, setError] = useState(''); const [error, setError] = useState('');
const [isCreated, setIsCreated] = useState(false); const [isCreated, setIsCreated] = useState(false);
const [loading, setLoading] = useState(false);
const handleSubmit = async (event) => { const handleSubmit = async (event) => {
event.preventDefault(); event.preventDefault();
if (loading) {
return;
}
setError(''); setError('');
setLoading(true);
try { try {
await register({ ...form, invite }); await register({ ...form, invite });
setIsCreated(true); setIsCreated(true);
} catch (requestError) { } catch (requestError) {
// Safely handle the error response structure
const detail = requestError.response?.data; const detail = requestError.response?.data;
const errorMessage = requestError.response?.data?.detail || requestError.message || 'Impossible de créer le compte.'; const errorMessage =
requestError.response?.data?.detail ||
requestError.message ||
'Impossible de créer le compte.';
setError( setError(
detail?.invite?.[0] detail?.invite?.[0]
|| detail?.first_name?.[0] || detail?.first_name?.[0]
|| detail?.last_name?.[0] || detail?.last_name?.[0]
|| detail?.username?.[0] || detail?.username?.[0]
|| detail?.password?.[0] || detail?.password?.[0]
|| detail?.password_confirm?.[0] || detail?.password_confirm?.[0]
|| errorMessage, || errorMessage,
); );
} finally {
setLoading(false);
} }
}; };
if (!invite) { if (!invite) {
return ( return (
<div className="auth-page page-layout flex h-full w-full"> <MainCard title="Lien invalide" icon={Ban}>
<Card className="flex h-full w-full"> <CardContent>
<CardHeader className="border-b"> Un lien d’invitation est nécessaire pour créer un compte.
<CardTitle>Lien invalide</CardTitle> </CardContent>
</CardHeader> </MainCard>
<CardContent>
<p>Un lien d’invitation est nécessaire pour créer un compte.</p>
</CardContent>
</Card>
</div>
); );
} }
if (isCreated) { if (isCreated) {
return ( return (
<div className="auth-page page-layout flex h-full w-full"> <MainCard title="Compte créé" icon={Check}>
<Card className="flex h-full w-full"> <CardContent>
<CardHeader className="border-b"> Votre compte est prêt. Vous pouvez maintenant vous connecter.
<CardTitle>Compte créé</CardTitle> <Button
</CardHeader> onClick={() => navigate('/login')}
<CardContent> className="mt-4"
<p>Votre compte est prêt. Vous pouvez maintenant vous connecter.</p> >
<Button onClick={() => navigate('/login')} className="mt-4"> Se connecter
Se connecter </Button>
</Button> </CardContent>
</CardContent> </MainCard>
</Card>
</div>
); );
} }
return ( return (
<div className="auth-page page-layout"> <MainCard title="Créer un compte" icon={UserKey}>
<Card className="mx-auto w-full max-w-md overflow-y-auto"> <CardContent>
<CardHeader className="border-b"> {error && (
<CardTitle>Créer un compte</CardTitle> <Alert
</CardHeader> variant="destructive"
<CardContent> className="mb-4"
{error && ( >
<Alert variant="destructive" className="mb-4"> <AlertDescription>
<AlertDescription>{error}</AlertDescription> {error}
</Alert> </AlertDescription>
)} </Alert>
<form onSubmit={handleSubmit} className="space-y-4"> )}
<div className="space-y-2">
<Label htmlFor="first-name">Prénom</Label> <form
<Input onSubmit={handleSubmit}
id="first-name" className="space-y-4"
required >
value={form.first_name} <div className="space-y-2">
onChange={(event) => setForm({ ...form, first_name: event.target.value })} <Label htmlFor="first-name">
/> Prénom
</div> </Label>
<div className="space-y-2">
<Label htmlFor="last-name">Nom</Label> <Input
<Input id="first-name"
id="last-name" required
required disabled={loading}
value={form.last_name} value={form.first_name}
onChange={(event) => setForm({ ...form, last_name: event.target.value })} onChange={(event) =>
/> setForm({
</div> ...form,
<div className="space-y-2"> first_name: event.target.value,
<Label htmlFor="username">Nom d'utilisateur</Label> })
<Input }
id="username" />
required </div>
value={form.username}
onChange={(event) => setForm({ ...form, username: event.target.value })} <div className="space-y-2">
/> <Label htmlFor="last-name">
</div> Nom
<div className="space-y-2"> </Label>
<Label htmlFor="password">Mot de passe</Label>
<Input <Input
id="password" id="last-name"
type="password" required
required disabled={loading}
minLength="8" value={form.last_name}
value={form.password} onChange={(event) =>
onChange={(event) => setForm({ ...form, password: event.target.value })} setForm({
/> ...form,
</div> last_name: event.target.value,
<div className="space-y-2"> })
<Label htmlFor="password-confirm">Confirmer le mot de passe</Label> }
<Input />
id="password-confirm" </div>
type="password"
required <div className="space-y-2">
minLength="8" <Label htmlFor="username">
value={form.password_confirm} Nom d'utilisateur
onChange={(event) => setForm({ ...form, password_confirm: event.target.value })} </Label>
/>
</div> <Input
<Button type="submit" className="w-full"> id="username"
Créer mon compte required
</Button> disabled={loading}
</form> value={form.username}
</CardContent> onChange={(event) =>
</Card> setForm({
</div> ...form,
username: event.target.value,
})
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">
Mot de passe
</Label>
<Input
id="password"
type="password"
required
minLength="8"
disabled={loading}
value={form.password}
onChange={(event) =>
setForm({
...form,
password: event.target.value,
})
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password-confirm">
Confirmer le mot de passe
</Label>
<Input
id="password-confirm"
type="password"
required
minLength="8"
disabled={loading}
value={form.password_confirm}
onChange={(event) =>
setForm({
...form,
password_confirm: event.target.value,
})
}
/>
</div>
<Button
type="submit"
className="w-full"
disabled={loading}
>
{loading ? (
<span className="flex items-center justify-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
Création...
</span>
) : (
'Créer mon compte'
)}
</Button>
</form>
</CardContent>
</MainCard>
); );
}; };
export default RegisterPage; export default RegisterPage;

File diff suppressed because it is too large Load Diff

View File

@ -6,6 +6,8 @@ import React, {
} from 'react'; } from 'react';
import { timeApi } from '../api/time'; import { timeApi } from '../api/time';
import { useAuth } from '../context/useAuth'; import { useAuth } from '../context/useAuth';
import LoadingCard from '@/components/LoadingCard';
import MainCard from '@/components/MainCard';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Card, Card,
@ -17,8 +19,8 @@ import { Badge } from '@/components/ui/badge';
import { import {
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Users,
CalendarDays, CalendarDays,
X,
} from 'lucide-react'; } from 'lucide-react';
const DEFAULT_START_HOUR = 8; const DEFAULT_START_HOUR = 8;
@ -78,6 +80,7 @@ const WeeklySummaryPage = ({ userId }) => {
}); });
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [initialLoading, setInitialLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [availableScheduleHeight, setAvailableScheduleHeight] = const [availableScheduleHeight, setAvailableScheduleHeight] =
@ -344,6 +347,7 @@ const WeeklySummaryPage = ({ userId }) => {
console.error(loadError); console.error(loadError);
} finally { } finally {
setLoading(false); setLoading(false);
setInitialLoading(false);
} }
}; };
@ -504,6 +508,7 @@ const WeeklySummaryPage = ({ userId }) => {
resizeObserver?.disconnect(); resizeObserver?.disconnect();
}; };
}, [ }, [
initialLoading,
loading, loading,
error, error,
showAllUsers, showAllUsers,
@ -971,35 +976,33 @@ const WeeklySummaryPage = ({ userId }) => {
/* /*
* ------------------------------------------------------------ * ------------------------------------------------------------
* États * État initial
* ------------------------------------------------------------ * ------------------------------------------------------------
*/ */
if (loading) { if (initialLoading) {
return ( return (
<Card className="page-layout page-state weekly-schedule flex h-full w-full"> <LoadingCard
<CardContent className="p-6"> icon={CalendarDays}
<div className="flex h-32 items-center justify-center"> title="Planning"
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-blue-500" /> message="Chargement du planning..."
/>
<span className="ml-3">
Chargement...
</span>
</div>
</CardContent>
</Card>
); );
} }
/*
* ------------------------------------------------------------
* Erreur
* ------------------------------------------------------------
*/
if (error) { if (error) {
return ( return (
<Card className="page-layout page-state weekly-schedule schedule-error flex h-full w-full"> <MainCard title="Erreur" icon={X}>
<CardContent className="p-6"> <CardContent className="p-6">
<div className="text-center text-red-500"> {error}
<p>{error}</p>
</div>
</CardContent> </CardContent>
</Card> </MainCard>
); );
} }
@ -1173,54 +1176,38 @@ const WeeklySummaryPage = ({ userId }) => {
})); }));
return ( return (
<Card <MainCard
className={`flex h-full min-h-0 w-full weekly-schedule page-layout ${ icon={CalendarDays}
isMobile className=
? 'overflow-visible' {isMobile
: 'overflow-hidden' ? 'overflow-visible'
}${ : 'overflow-hidden'
isAdmin }
? ' multi-user-schedule' title={
: '' isAdmin ? (
}`} <>
> {showAllUsers
<CardHeader className="shrink-0 border-b"> ? 'Planning de tous les utilisateurs'
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"> : `Planning de ${
<div className="min-w-0"> selectedUser
<CardTitle className="flex items-center gap-2"> ? getUserLabel(selectedUser)
{isAdmin ? ( : 'l’utilisateur'
<> }`}
<Users className="h-5 w-5" /> </>
) : (
{showAllUsers <>
? 'Récapitulatif de tous les utilisateurs' Planning
: `Récapitulatif de ${ </>
selectedUser )
? getUserLabel( }
selectedUser, headerContent={
) <>
: 'l’utilisateur'
}`}
</>
) : (
<>
<CalendarDays className="h-5 w-5" />
Emploi du temps
hebdomadaire
</>
)}
</CardTitle>
</div>
{!isMobile && ( {!isMobile && (
<div className="flex items-center gap-2"> <>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => onClick={() => navigateWeek(-1)}
navigateWeek(-1)
}
className="flex items-center gap-1" className="flex items-center gap-1"
> >
<ChevronLeft className="h-4 w-4" /> <ChevronLeft className="h-4 w-4" />
@ -1232,45 +1219,35 @@ const WeeklySummaryPage = ({ userId }) => {
className="text-sm" className="text-sm"
> >
Du{' '} Du{' '}
{weekDays[0].toLocaleDateString( {weekDays[0].toLocaleDateString('fr-FR', {
'fr-FR', day: '2-digit',
{ month: 'short',
day: '2-digit', })}{' '}
month: 'short',
},
)}{' '}
au{' '} au{' '}
{weekDays[6].toLocaleDateString( {weekDays[6].toLocaleDateString('fr-FR', {
'fr-FR', day: '2-digit',
{ month: 'short',
day: '2-digit', })}
month: 'short',
},
)}
</Badge> </Badge>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => onClick={() => navigateWeek(1)}
navigateWeek(1)
}
className="flex items-center gap-1" className="flex items-center gap-1"
> >
Suivante Suivante
<ChevronRight className="h-4 w-4" /> <ChevronRight className="h-4 w-4" />
</Button> </Button>
</div> </>
)} )}
{isMobile && ( {isMobile && (
<div className="flex items-center justify-between gap-2 pt-2"> <>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => onClick={() => navigateDay(-1)}
navigateDay(-1)
}
> >
<ChevronLeft className="h-4 w-4" /> <ChevronLeft className="h-4 w-4" />
</Button> </Button>
@ -1279,9 +1256,7 @@ const WeeklySummaryPage = ({ userId }) => {
variant="secondary" variant="secondary"
className="text-sm" className="text-sm"
> >
{weekDays[ {weekDays[selectedDayIndex].toLocaleDateString(
selectedDayIndex
].toLocaleDateString(
'fr-FR', 'fr-FR',
{ {
weekday: 'long', weekday: 'long',
@ -1294,17 +1269,15 @@ const WeeklySummaryPage = ({ userId }) => {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => onClick={() => navigateDay(1)}
navigateDay(1)
}
> >
<ChevronRight className="h-4 w-4" /> <ChevronRight className="h-4 w-4" />
</Button> </Button>
</div> </>
)} )}
</div> </>
</CardHeader> }
>
<CardContent className="flex min-h-0 flex-1 flex-col overflow-hidden"> <CardContent className="flex min-h-0 flex-1 flex-col overflow-hidden">
{/* Sélection utilisateur */} {/* Sélection utilisateur */}
{isAdmin && ( {isAdmin && (
@ -1969,7 +1942,7 @@ const WeeklySummaryPage = ({ userId }) => {
)} )}
</div> </div>
</CardContent> </CardContent>
</Card> </MainCard>
); );
}; };