clean
This commit is contained in:
parent
876ec75a4e
commit
5298d69e1f
@ -45,6 +45,7 @@ INSTALLED_APPS = [
|
||||
# Local apps
|
||||
'users',
|
||||
'time_tracking',
|
||||
'events',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
@ -155,3 +156,4 @@ SIMPLE_JWT = {
|
||||
# Default primary key field type
|
||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
|
||||
|
||||
|
||||
|
||||
@ -27,4 +27,5 @@ urlpatterns = [
|
||||
path('api/auth/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
|
||||
path('api/users/', include('users.urls')),
|
||||
path('api/time/', include('time_tracking.urls')),
|
||||
path('api/event/', include('events.urls')),
|
||||
]
|
||||
|
||||
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.db import transaction
|
||||
from rest_framework import serializers
|
||||
|
||||
from users.serializers import UserSerializer
|
||||
from .models import TimeEntry
|
||||
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = User
|
||||
fields = [
|
||||
'id',
|
||||
'username',
|
||||
'first_name',
|
||||
'last_name',
|
||||
]
|
||||
|
||||
|
||||
class TimeEntrySerializer(serializers.ModelSerializer):
|
||||
user = UserSerializer(read_only=True)
|
||||
|
||||
|
||||
@ -69,7 +69,7 @@ class UsersConfig(AppConfig):
|
||||
|
||||
except Exception as e:
|
||||
# Don't fail the entire Django startup if there's an error
|
||||
logger.error(f'Error creating admin user: {e}')
|
||||
print(f'Error creating admin user: {e}')
|
||||
|
||||
# Start the superuser creation in a separate thread to avoid the warning
|
||||
thread = threading.Thread(target=create_superuser_if_needed, daemon=True)
|
||||
|
||||
@ -20,6 +20,7 @@ import AdminUsersPage from './pages/AdminUsersPage';
|
||||
import RegisterPage from './pages/RegisterPage';
|
||||
import TimeClockPage from './pages/TimeClockPage';
|
||||
import WeeklySummaryPage from './pages/WeeklySummaryPage';
|
||||
import CalendarPage from './pages/CalendarPage';
|
||||
import { useAuth } from './context/useAuth';
|
||||
|
||||
// Composant personnalisé pour le trigger mobile avec icône menu
|
||||
@ -108,6 +109,15 @@ function App() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/calendar"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<CalendarPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="*"
|
||||
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,
|
||||
LogOut,
|
||||
Wheat,
|
||||
Calendar,
|
||||
} from 'lucide-react';
|
||||
|
||||
const NavBar = () => {
|
||||
@ -74,27 +75,17 @@ const NavBar = () => {
|
||||
icon: User,
|
||||
},
|
||||
{
|
||||
title: 'Résumé hebdomadaire',
|
||||
title: 'Planning',
|
||||
href: '/weekly-summary',
|
||||
icon: Clock,
|
||||
icon: CalendarDays,
|
||||
},
|
||||
{
|
||||
title: 'Pointage',
|
||||
href: '/pointage',
|
||||
icon: CalendarDays,
|
||||
icon: Clock,
|
||||
},
|
||||
];
|
||||
|
||||
/*
|
||||
* Hauteur d'une ligne de navigation.
|
||||
*
|
||||
* Le SidebarMenuButton natif fait 32px.
|
||||
* On lui donne une ligne de 48px et on le centre.
|
||||
*
|
||||
* Le header de groupe fait lui aussi 32px.
|
||||
* En mode plié, il est remplacé visuellement par
|
||||
* une séparation, mais conserve exactement ses 32px.
|
||||
*/
|
||||
const menuItemClass = 'h-12 min-h-12 flex items-center';
|
||||
|
||||
const buttonClass = `
|
||||
@ -114,17 +105,6 @@ const NavBar = () => {
|
||||
text-base
|
||||
`;
|
||||
|
||||
/*
|
||||
* Header de groupe :
|
||||
*
|
||||
* Déplié :
|
||||
* [ Navigation ] 32px
|
||||
*
|
||||
* Plié :
|
||||
* [ ---------------------- ] 32px
|
||||
*
|
||||
* Même hauteur dans les deux cas.
|
||||
*/
|
||||
const GroupHeader = ({ children }) => (
|
||||
<div
|
||||
className="
|
||||
@ -247,6 +227,37 @@ const NavBar = () => {
|
||||
</SidebarGroupContent>
|
||||
</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 */}
|
||||
{user.is_admin && (
|
||||
<SidebarGroup className="p-2">
|
||||
@ -269,7 +280,7 @@ const NavBar = () => {
|
||||
<Users className="size-6 shrink-0" />
|
||||
|
||||
<span className="truncate text-base">
|
||||
Comptes existants
|
||||
Utilisateurs
|
||||
</span>
|
||||
</NavLink>
|
||||
}
|
||||
|
||||
@ -11,7 +11,7 @@ function Card({
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl border border-border bg-card py-(--card-spacing) text-sm text-card-foreground [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { userApi } from '../api/users';
|
||||
import LoadingCard from '@/components/LoadingCard';
|
||||
import MainCard from '@/components/MainCard';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Table,
|
||||
@ -10,7 +12,7 @@ import {
|
||||
TableRow
|
||||
} from '@/components/ui/table';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { CardContent } from '@/components/ui/card';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
@ -18,7 +20,7 @@ import ConfirmDialog from '@/components/ConfirmDialog';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useAuth } from '../context/useAuth';
|
||||
|
||||
import { Users} from 'lucide-react';
|
||||
const AdminUsersPage = () => {
|
||||
const { user, logout } = useAuth();
|
||||
const [users, setUsers] = useState([]);
|
||||
@ -147,23 +149,16 @@ const AdminUsersPage = () => {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="page-container page-layout admin-page">
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle>Gestion des comptes</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p>Chargement des utilisateurs...</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<LoadingCard
|
||||
icon={Users}
|
||||
title="Gestion des utilisateurs"
|
||||
message="Chargement des utilisateurs..."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="page-container page-layout admin-page h-full w-full">
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle>Gestion des comptes</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<MainCard title="Gestion des utilisateurs" icon={Users}>
|
||||
<CardContent className="overflow-y-auto">
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
@ -171,36 +166,84 @@ const AdminUsersPage = () => {
|
||||
</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">
|
||||
<Table>
|
||||
<Table className="w-full">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Nom</TableHead>
|
||||
<TableHead>Couleur</TableHead>
|
||||
<TableHead>Rôle</TableHead>
|
||||
<TableHead>Inscrit le</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
<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>{user.first_name} {user.last_name}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{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>
|
||||
<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>
|
||||
<Badge variant={user.is_admin ? "default" : "secondary"}>
|
||||
|
||||
<TableCell className="text-center">
|
||||
<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 className="text-center">
|
||||
{new Date(user.date_joined).toLocaleDateString('fr-FR')}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
<Dialog open={editingUser === user.id} onOpenChange={(open) => {
|
||||
<div className="flex justify-center gap-2">
|
||||
<Dialog
|
||||
open={editingUser === user.id}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setEditingUser(null);
|
||||
} else {
|
||||
@ -215,88 +258,149 @@ const AdminUsersPage = () => {
|
||||
is_superuser: user.is_superuser,
|
||||
});
|
||||
}
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" onClick={() => setEditingUser(user.id)}>
|
||||
<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>
|
||||
<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 })}
|
||||
onChange={(event) =>
|
||||
setEditForm({
|
||||
...editForm,
|
||||
first_name: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`last-name-${user.id}`}>Nom</Label>
|
||||
<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 })}
|
||||
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 })}
|
||||
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 })}
|
||||
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 })}
|
||||
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 })}
|
||||
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({
|
||||
onCheckedChange={(checked) =>
|
||||
setEditForm({
|
||||
...editForm,
|
||||
is_staff: checked,
|
||||
is_superuser: checked ? editForm.is_superuser : false,
|
||||
})}
|
||||
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>
|
||||
|
||||
<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"
|
||||
@ -308,6 +412,7 @@ const AdminUsersPage = () => {
|
||||
</Button>
|
||||
</ConfirmDialog>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
title="Supprimer l'utilisateur"
|
||||
description="Êtes-vous sûr de vouloir supprimer cet utilisateur ?"
|
||||
@ -324,36 +429,8 @@ const AdminUsersPage = () => {
|
||||
</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
|
||||
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>
|
||||
</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 LoadingCard from '@/components/LoadingCard';
|
||||
import MainCard from '@/components/MainCard';
|
||||
import { useAuth } from '../context/useAuth';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
CardContent,
|
||||
} from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { User } from 'lucide-react';
|
||||
|
||||
const HomePage = () => {
|
||||
const { user } = useAuth();
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="flex h-full w-full">
|
||||
<Card className="mx-auto h-full w-full">
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle>Profil Utilisateur</CardTitle>
|
||||
</CardHeader>
|
||||
<LoadingCard
|
||||
icon={User}
|
||||
title="Profil"
|
||||
message="Chargement du profil..."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
<CardContent overflow-y-auto>
|
||||
{user && (
|
||||
return (
|
||||
<MainCard title="Profil" icon={User}>
|
||||
|
||||
<CardContent className="overflow-y-auto">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">Prénom:</span>
|
||||
@ -56,10 +66,8 @@ const HomePage = () => {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</MainCard>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -1,79 +1,125 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../context/useAuth';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import MainCard from '../components/MainCard';
|
||||
import { CardContent } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
} from '@/components/ui/alert';
|
||||
import {KeyRound} from 'lucide-react';
|
||||
const LoginPage = () => {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const from = location.state?.from?.pathname || "/";
|
||||
const from =
|
||||
location.state?.from?.pathname || '/';
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await login(username, password);
|
||||
navigate(from, { replace: true });
|
||||
} catch (err) {
|
||||
setError('Nom d\'utilisateur ou mot de passe incorrect');
|
||||
setError(
|
||||
'Nom d\'utilisateur ou mot de passe incorrect',
|
||||
);
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="auth-page page-layout flex h-full w-full">
|
||||
<Card className="flex h-full min-h-0 w-full overflow-y-auto">
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle>Connexion</CardTitle>
|
||||
</CardHeader>
|
||||
<MainCard title="Connexion" icon={KeyRound}>
|
||||
<CardContent>
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
<Alert
|
||||
variant="destructive"
|
||||
className="mb-4"
|
||||
>
|
||||
<AlertDescription>
|
||||
{error}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">Nom d'utilisateur:</Label>
|
||||
<Label htmlFor="username">
|
||||
Nom d'utilisateur:
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
onChange={(e) =>
|
||||
setUsername(e.target.value)
|
||||
}
|
||||
disabled={loading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Mot de passe:</Label>
|
||||
<Label htmlFor="password">
|
||||
Mot de passe:
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
className="current-password"
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onChange={(e) =>
|
||||
setPassword(e.target.value)
|
||||
}
|
||||
disabled={loading}
|
||||
required
|
||||
/>
|
||||
</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>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</MainCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
|
||||
|
||||
@ -1,15 +1,23 @@
|
||||
import { useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/useAuth';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import MainCard from '../components/MainCard';
|
||||
import { CardContent } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
} from '@/components/ui/alert';
|
||||
import { UserKey, Ban, Check} from 'lucide-react';
|
||||
const RegisterPage = () => {
|
||||
const invite = new URLSearchParams(useLocation().search).get('invite') || '';
|
||||
const invite =
|
||||
new URLSearchParams(useLocation().search).get('invite') || '';
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { register } = useAuth();
|
||||
|
||||
const [form, setForm] = useState({
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
@ -17,19 +25,30 @@ const RegisterPage = () => {
|
||||
password: '',
|
||||
password_confirm: '',
|
||||
});
|
||||
|
||||
const [error, setError] = useState('');
|
||||
const [isCreated, setIsCreated] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await register({ ...form, invite });
|
||||
setIsCreated(true);
|
||||
} catch (requestError) {
|
||||
// Safely handle the error response structure
|
||||
const detail = requestError.response?.data;
|
||||
const errorMessage = requestError.response?.data?.detail || requestError.message || 'Impossible de créer le compte.';
|
||||
const errorMessage =
|
||||
requestError.response?.data?.detail ||
|
||||
requestError.message ||
|
||||
'Impossible de créer le compte.';
|
||||
|
||||
setError(
|
||||
detail?.invite?.[0]
|
||||
@ -40,111 +59,171 @@ const RegisterPage = () => {
|
||||
|| detail?.password_confirm?.[0]
|
||||
|| errorMessage,
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!invite) {
|
||||
return (
|
||||
<div className="auth-page page-layout flex h-full w-full">
|
||||
<Card className="flex h-full w-full">
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle>Lien invalide</CardTitle>
|
||||
</CardHeader>
|
||||
<MainCard title="Lien invalide" icon={Ban}>
|
||||
<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>
|
||||
</Card>
|
||||
</div>
|
||||
</MainCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (isCreated) {
|
||||
return (
|
||||
<div className="auth-page page-layout flex h-full w-full">
|
||||
<Card className="flex h-full w-full">
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle>Compte créé</CardTitle>
|
||||
</CardHeader>
|
||||
<MainCard title="Compte créé" icon={Check}>
|
||||
<CardContent>
|
||||
<p>Votre compte est prêt. Vous pouvez maintenant vous connecter.</p>
|
||||
<Button onClick={() => navigate('/login')} className="mt-4">
|
||||
Votre compte est prêt. Vous pouvez maintenant vous connecter.
|
||||
<Button
|
||||
onClick={() => navigate('/login')}
|
||||
className="mt-4"
|
||||
>
|
||||
Se connecter
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</MainCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-page page-layout">
|
||||
<Card className="mx-auto w-full max-w-md overflow-y-auto">
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle>Créer un compte</CardTitle>
|
||||
</CardHeader>
|
||||
<MainCard title="Créer un compte" icon={UserKey}>
|
||||
<CardContent>
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
<Alert
|
||||
variant="destructive"
|
||||
className="mb-4"
|
||||
>
|
||||
<AlertDescription>
|
||||
{error}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="first-name">Prénom</Label>
|
||||
<Label htmlFor="first-name">
|
||||
Prénom
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
id="first-name"
|
||||
required
|
||||
disabled={loading}
|
||||
value={form.first_name}
|
||||
onChange={(event) => setForm({ ...form, first_name: event.target.value })}
|
||||
onChange={(event) =>
|
||||
setForm({
|
||||
...form,
|
||||
first_name: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="last-name">Nom</Label>
|
||||
<Label htmlFor="last-name">
|
||||
Nom
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
id="last-name"
|
||||
required
|
||||
disabled={loading}
|
||||
value={form.last_name}
|
||||
onChange={(event) => setForm({ ...form, last_name: event.target.value })}
|
||||
onChange={(event) =>
|
||||
setForm({
|
||||
...form,
|
||||
last_name: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">Nom d'utilisateur</Label>
|
||||
<Label htmlFor="username">
|
||||
Nom d'utilisateur
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
id="username"
|
||||
required
|
||||
disabled={loading}
|
||||
value={form.username}
|
||||
onChange={(event) => setForm({ ...form, username: event.target.value })}
|
||||
onChange={(event) =>
|
||||
setForm({
|
||||
...form,
|
||||
username: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Mot de passe</Label>
|
||||
<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 })}
|
||||
onChange={(event) =>
|
||||
setForm({
|
||||
...form,
|
||||
password: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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
|
||||
id="password-confirm"
|
||||
type="password"
|
||||
required
|
||||
minLength="8"
|
||||
disabled={loading}
|
||||
value={form.password_confirm}
|
||||
onChange={(event) => setForm({ ...form, password_confirm: event.target.value })}
|
||||
onChange={(event) =>
|
||||
setForm({
|
||||
...form,
|
||||
password_confirm: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</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>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</MainCard>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -2,7 +2,8 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import { BrowserQRCodeReader } from '@zxing/browser';
|
||||
import { timeApi } from '../api/time';
|
||||
import { useAuth } from '../context/useAuth';
|
||||
|
||||
import LoadingCard from '@/components/LoadingCard';
|
||||
import MainCard from '@/components/MainCard';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@ -12,7 +13,6 @@ import {
|
||||
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
import {
|
||||
Alert,
|
||||
@ -121,7 +121,18 @@ const formatDateLabel = (value) => {
|
||||
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',
|
||||
{
|
||||
day: '2-digit',
|
||||
@ -129,6 +140,18 @@ const formatDateLabel = (value) => {
|
||||
year: 'numeric',
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className="lg:hidden">
|
||||
{longDate}
|
||||
</span>
|
||||
|
||||
<span className="hidden lg:inline">
|
||||
{numericDate}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -334,8 +357,7 @@ const DatePicker = ({
|
||||
|
||||
const TimePicker = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Choisir une heure',
|
||||
onChange
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
@ -424,7 +446,6 @@ const TimePicker = ({
|
||||
);
|
||||
};
|
||||
|
||||
// PC : une molette = une unité
|
||||
const handleWheel = (
|
||||
event,
|
||||
type,
|
||||
@ -442,7 +463,6 @@ const TimePicker = ({
|
||||
}
|
||||
};
|
||||
|
||||
// MOBILE : début du déplacement
|
||||
const handleTouchStart = (event) => {
|
||||
const touch = event.touches[0];
|
||||
|
||||
@ -453,7 +473,6 @@ const TimePicker = ({
|
||||
};
|
||||
};
|
||||
|
||||
// MOBILE : déplacement continu
|
||||
const handleTouchMove = (
|
||||
event,
|
||||
type,
|
||||
@ -530,7 +549,19 @@ const TimePicker = ({
|
||||
: 'text-muted-foreground'
|
||||
}
|
||||
>
|
||||
{value || placeholder}
|
||||
{value ? (
|
||||
value
|
||||
) : (
|
||||
<>
|
||||
<span className="lg:hidden">
|
||||
Choisir une heure
|
||||
</span>
|
||||
|
||||
<span className="hidden lg:inline">
|
||||
Choisir
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</PopoverTrigger>
|
||||
|
||||
@ -551,7 +582,6 @@ const TimePicker = ({
|
||||
<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" />
|
||||
|
||||
{/* HEURES */}
|
||||
<div
|
||||
className="flex-1 touch-none select-none"
|
||||
onWheel={(event) =>
|
||||
@ -597,14 +627,12 @@ const TimePicker = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* : */}
|
||||
<div className="flex items-center justify-center">
|
||||
<span className="font-bold">
|
||||
:
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* MINUTES */}
|
||||
<div
|
||||
className="flex-1 touch-none select-none"
|
||||
onWheel={(event) =>
|
||||
@ -739,6 +767,11 @@ const TimeClockPage = () => {
|
||||
setLoading,
|
||||
] = useState(true);
|
||||
|
||||
const [
|
||||
initialLoading,
|
||||
setInitialLoading,
|
||||
] = useState(true);
|
||||
|
||||
const [
|
||||
isScannerDialogOpen,
|
||||
setIsScannerDialogOpen,
|
||||
@ -1345,6 +1378,7 @@ const TimeClockPage = () => {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setInitialLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@ -2446,6 +2480,21 @@ const TimeClockPage = () => {
|
||||
);
|
||||
|
||||
|
||||
/* =========================================================
|
||||
INITIAL LOADING
|
||||
========================================================= */
|
||||
|
||||
if (initialLoading) {
|
||||
return (
|
||||
<LoadingCard
|
||||
icon={Clock}
|
||||
title="Pointage"
|
||||
message="Chargement du module de pointage..."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/* =========================================================
|
||||
RENDER
|
||||
========================================================= */
|
||||
@ -2454,25 +2503,10 @@ const TimeClockPage = () => {
|
||||
getScanConflictConfig();
|
||||
|
||||
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">
|
||||
|
||||
{/* =========================
|
||||
ALERTES
|
||||
========================== */}
|
||||
|
||||
{error && (
|
||||
<Alert
|
||||
variant="destructive"
|
||||
@ -2584,14 +2618,13 @@ const TimeClockPage = () => {
|
||||
}
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<div className="grid grid-cols-[140px_minmax(0,1fr)] items-center gap-3">
|
||||
<Label>
|
||||
{
|
||||
scanConflictConfig.timeLabel
|
||||
}
|
||||
</Label>
|
||||
|
||||
<div className="mt-2">
|
||||
<TimePicker
|
||||
value={
|
||||
conflictTime
|
||||
@ -2601,7 +2634,6 @@ const TimeClockPage = () => {
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
|
||||
<Button
|
||||
@ -2709,8 +2741,8 @@ const TimeClockPage = () => {
|
||||
====================================================== */}
|
||||
|
||||
<div
|
||||
className={`grid w-full min-h-0 grid-cols-1 gap-6 lg:flex-1 ${
|
||||
isAdmin ? 'lg:grid-cols-2' : ''
|
||||
className={`grid w-full min-h-0 grid-cols-1 gap-6 ${
|
||||
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">
|
||||
<Card className="lg:h-full">
|
||||
|
||||
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle>
|
||||
Gestion des pointages
|
||||
@ -2730,15 +2761,18 @@ const TimeClockPage = () => {
|
||||
</CardHeader>
|
||||
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
<CardContent className="space-y-4">
|
||||
|
||||
{/* UTILISATEUR */}
|
||||
<Card className="shadow-none">
|
||||
|
||||
<div>
|
||||
|
||||
<Label htmlFor="selected-user">
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle className="text-base">
|
||||
Voir les pointages de :
|
||||
</Label>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
|
||||
<CardContent className="space-y-3">
|
||||
|
||||
<select
|
||||
id="selected-user"
|
||||
@ -2762,7 +2796,7 @@ const TimeClockPage = () => {
|
||||
|
||||
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 ===
|
||||
@ -2793,7 +2827,7 @@ const TimeClockPage = () => {
|
||||
</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
|
||||
type="checkbox"
|
||||
@ -2823,32 +2857,29 @@ const TimeClockPage = () => {
|
||||
|
||||
</label>
|
||||
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
</Card>
|
||||
|
||||
{/* SEPARATION */}
|
||||
{(!showAllUsers || editingEntry) && (
|
||||
<Card className="shadow-none">
|
||||
|
||||
{!showAllUsers && (
|
||||
<div className="border-t pt-6">
|
||||
|
||||
<div className="mb-4">
|
||||
<h3 className="text-sm font-medium">
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle className="text-base">
|
||||
{editingEntry
|
||||
? 'Modifier le pointage'
|
||||
: `Ajouter un pointage pour${
|
||||
selectedUser
|
||||
? ` ${getUserLabel(
|
||||
selectedUser,
|
||||
? `Modifier un pointage de ${getUserLabel(
|
||||
users.find((u) => u.id === editingEntry.user.id)?.first_name
|
||||
)}`
|
||||
: `Ajouter un pointage à ${
|
||||
selectedUser
|
||||
? ` ${getUserLabel(selectedUser)}`
|
||||
: ''
|
||||
}`}
|
||||
</h3>
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
|
||||
{/* =============================
|
||||
EDITION
|
||||
============================== */}
|
||||
<CardContent>
|
||||
|
||||
{editingEntry ? (
|
||||
<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 */}
|
||||
|
||||
<div>
|
||||
<Label>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Label className="w-[115px] shrink-0">
|
||||
Date d'entrée :
|
||||
</Label>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
{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(
|
||||
editForm.date,
|
||||
)}
|
||||
@ -2888,8 +2920,7 @@ const TimeClockPage = () => {
|
||||
|
||||
end_date:
|
||||
editForm.end_date &&
|
||||
editForm.end_date <
|
||||
value
|
||||
editForm.end_date < value
|
||||
? value
|
||||
: editForm.end_date,
|
||||
},
|
||||
@ -2898,17 +2929,47 @@ const TimeClockPage = () => {
|
||||
/>
|
||||
)}
|
||||
</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 */}
|
||||
|
||||
<div>
|
||||
<Label>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Label className="w-[115px] shrink-0">
|
||||
Date de sortie :
|
||||
</Label>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
{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(
|
||||
editForm.end_date,
|
||||
)}
|
||||
@ -2935,41 +2996,17 @@ const TimeClockPage = () => {
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
|
||||
|
||||
{/* HEURE DE SORTIE */}
|
||||
|
||||
<div>
|
||||
<Label>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Label className="w-[115px] shrink-0">
|
||||
Heure de sortie :
|
||||
</Label>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<TimePicker
|
||||
value={
|
||||
editForm.end_time
|
||||
@ -2987,6 +3024,7 @@ const TimeClockPage = () => {
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@ -3024,25 +3062,22 @@ const TimeClockPage = () => {
|
||||
</form>
|
||||
) : (
|
||||
|
||||
/* =============================
|
||||
AJOUT
|
||||
============================== */
|
||||
|
||||
<form
|
||||
onSubmit={
|
||||
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 */}
|
||||
|
||||
<div>
|
||||
<Label>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Label className="w-[115px] shrink-0">
|
||||
Date d'entrée :
|
||||
</Label>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<DatePicker
|
||||
value={
|
||||
newEntry.date
|
||||
@ -3058,8 +3093,7 @@ const TimeClockPage = () => {
|
||||
|
||||
end_date:
|
||||
newEntry.end_date &&
|
||||
newEntry.end_date <
|
||||
value
|
||||
newEntry.end_date < value
|
||||
? value
|
||||
: newEntry.end_date,
|
||||
},
|
||||
@ -3067,15 +3101,45 @@ const TimeClockPage = () => {
|
||||
}
|
||||
/>
|
||||
</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 */}
|
||||
|
||||
<div>
|
||||
<Label>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Label className="w-[115px] shrink-0">
|
||||
Date de sortie :
|
||||
</Label>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<DatePicker
|
||||
value={
|
||||
newEntry.end_date
|
||||
@ -3096,41 +3160,17 @@ const TimeClockPage = () => {
|
||||
}
|
||||
/>
|
||||
</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>
|
||||
|
||||
|
||||
{/* HEURE DE SORTIE */}
|
||||
|
||||
<div>
|
||||
<Label>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Label className="w-[115px] shrink-0">
|
||||
Heure de sortie :
|
||||
</Label>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<TimePicker
|
||||
value={
|
||||
newEntry.end_time
|
||||
@ -3148,6 +3188,7 @@ const TimeClockPage = () => {
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@ -3162,9 +3203,12 @@ const TimeClockPage = () => {
|
||||
</form>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
</Card>
|
||||
)}
|
||||
|
||||
|
||||
</CardContent>
|
||||
|
||||
</Card>
|
||||
@ -3191,18 +3235,9 @@ const TimeClockPage = () => {
|
||||
</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">
|
||||
|
||||
{loading ? (
|
||||
<p>
|
||||
Chargement des
|
||||
pointages...
|
||||
</p>
|
||||
) : displayedTimeEntries.length ===
|
||||
{displayedTimeEntries.length ===
|
||||
0 ? (
|
||||
<p>
|
||||
Aucun pointage
|
||||
@ -3281,8 +3316,6 @@ const TimeClockPage = () => {
|
||||
)}
|
||||
|
||||
|
||||
{/* DATE */}
|
||||
|
||||
<TableCell>
|
||||
{formatEntryDate(
|
||||
entry,
|
||||
@ -3290,8 +3323,6 @@ const TimeClockPage = () => {
|
||||
</TableCell>
|
||||
|
||||
|
||||
{/* ENTRÉE */}
|
||||
|
||||
<TableCell>
|
||||
{formatTime(
|
||||
entry.start_time,
|
||||
@ -3299,8 +3330,6 @@ const TimeClockPage = () => {
|
||||
</TableCell>
|
||||
|
||||
|
||||
{/* SORTIE */}
|
||||
|
||||
<TableCell>
|
||||
{formatTime(
|
||||
entry.end_time,
|
||||
@ -3308,8 +3337,6 @@ const TimeClockPage = () => {
|
||||
</TableCell>
|
||||
|
||||
|
||||
{/* DURÉE */}
|
||||
|
||||
<TableCell className="text-center">
|
||||
{duration !==
|
||||
null &&
|
||||
@ -3328,8 +3355,6 @@ const TimeClockPage = () => {
|
||||
</TableCell>
|
||||
|
||||
|
||||
{/* STATUT */}
|
||||
|
||||
<TableCell>
|
||||
{entry.start_time &&
|
||||
entry.end_time ? (
|
||||
@ -3348,8 +3373,6 @@ const TimeClockPage = () => {
|
||||
</TableCell>
|
||||
|
||||
|
||||
{/* ACTIONS */}
|
||||
|
||||
{isAdmin && (
|
||||
<TableCell>
|
||||
|
||||
@ -3422,7 +3445,7 @@ const TimeClockPage = () => {
|
||||
|
||||
</CardContent>
|
||||
|
||||
</Card>
|
||||
</MainCard>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -6,6 +6,8 @@ import React, {
|
||||
} from 'react';
|
||||
import { timeApi } from '../api/time';
|
||||
import { useAuth } from '../context/useAuth';
|
||||
import LoadingCard from '@/components/LoadingCard';
|
||||
import MainCard from '@/components/MainCard';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
@ -17,8 +19,8 @@ import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Users,
|
||||
CalendarDays,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
|
||||
const DEFAULT_START_HOUR = 8;
|
||||
@ -78,6 +80,7 @@ const WeeklySummaryPage = ({ userId }) => {
|
||||
});
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [initialLoading, setInitialLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const [availableScheduleHeight, setAvailableScheduleHeight] =
|
||||
@ -344,6 +347,7 @@ const WeeklySummaryPage = ({ userId }) => {
|
||||
console.error(loadError);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setInitialLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@ -504,6 +508,7 @@ const WeeklySummaryPage = ({ userId }) => {
|
||||
resizeObserver?.disconnect();
|
||||
};
|
||||
}, [
|
||||
initialLoading,
|
||||
loading,
|
||||
error,
|
||||
showAllUsers,
|
||||
@ -971,35 +976,33 @@ const WeeklySummaryPage = ({ userId }) => {
|
||||
|
||||
/*
|
||||
* ------------------------------------------------------------
|
||||
* États
|
||||
* État initial
|
||||
* ------------------------------------------------------------
|
||||
*/
|
||||
|
||||
if (loading) {
|
||||
if (initialLoading) {
|
||||
return (
|
||||
<Card className="page-layout page-state weekly-schedule flex h-full w-full">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-blue-500" />
|
||||
|
||||
<span className="ml-3">
|
||||
Chargement...
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<LoadingCard
|
||||
icon={CalendarDays}
|
||||
title="Planning"
|
||||
message="Chargement du planning..."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* ------------------------------------------------------------
|
||||
* Erreur
|
||||
* ------------------------------------------------------------
|
||||
*/
|
||||
|
||||
if (error) {
|
||||
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">
|
||||
<div className="text-center text-red-500">
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
{error}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</MainCard>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1173,54 +1176,38 @@ const WeeklySummaryPage = ({ userId }) => {
|
||||
}));
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={`flex h-full min-h-0 w-full weekly-schedule page-layout ${
|
||||
isMobile
|
||||
<MainCard
|
||||
icon={CalendarDays}
|
||||
className=
|
||||
{isMobile
|
||||
? 'overflow-visible'
|
||||
: 'overflow-hidden'
|
||||
}${
|
||||
isAdmin
|
||||
? ' multi-user-schedule'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<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 ? (
|
||||
}
|
||||
title={
|
||||
isAdmin ? (
|
||||
<>
|
||||
<Users className="h-5 w-5" />
|
||||
|
||||
{showAllUsers
|
||||
? 'Récapitulatif de tous les utilisateurs'
|
||||
: `Récapitulatif de ${
|
||||
? 'Planning de tous les utilisateurs'
|
||||
: `Planning de ${
|
||||
selectedUser
|
||||
? getUserLabel(
|
||||
selectedUser,
|
||||
)
|
||||
? getUserLabel(selectedUser)
|
||||
: 'l’utilisateur'
|
||||
}`}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CalendarDays className="h-5 w-5" />
|
||||
|
||||
Emploi du temps
|
||||
hebdomadaire
|
||||
Planning
|
||||
</>
|
||||
)}
|
||||
</CardTitle>
|
||||
</div>
|
||||
|
||||
)
|
||||
}
|
||||
headerContent={
|
||||
<>
|
||||
{!isMobile && (
|
||||
<div className="flex items-center gap-2">
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
navigateWeek(-1)
|
||||
}
|
||||
onClick={() => navigateWeek(-1)}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
@ -1232,45 +1219,35 @@ const WeeklySummaryPage = ({ userId }) => {
|
||||
className="text-sm"
|
||||
>
|
||||
Du{' '}
|
||||
{weekDays[0].toLocaleDateString(
|
||||
'fr-FR',
|
||||
{
|
||||
{weekDays[0].toLocaleDateString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
},
|
||||
)}{' '}
|
||||
})}{' '}
|
||||
au{' '}
|
||||
{weekDays[6].toLocaleDateString(
|
||||
'fr-FR',
|
||||
{
|
||||
{weekDays[6].toLocaleDateString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
},
|
||||
)}
|
||||
})}
|
||||
</Badge>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
navigateWeek(1)
|
||||
}
|
||||
onClick={() => navigateWeek(1)}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
Suivante
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isMobile && (
|
||||
<div className="flex items-center justify-between gap-2 pt-2">
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
navigateDay(-1)
|
||||
}
|
||||
onClick={() => navigateDay(-1)}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
@ -1279,9 +1256,7 @@ const WeeklySummaryPage = ({ userId }) => {
|
||||
variant="secondary"
|
||||
className="text-sm"
|
||||
>
|
||||
{weekDays[
|
||||
selectedDayIndex
|
||||
].toLocaleDateString(
|
||||
{weekDays[selectedDayIndex].toLocaleDateString(
|
||||
'fr-FR',
|
||||
{
|
||||
weekday: 'long',
|
||||
@ -1294,17 +1269,15 @@ const WeeklySummaryPage = ({ userId }) => {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
navigateDay(1)
|
||||
}
|
||||
onClick={() => navigateDay(1)}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
</>
|
||||
}
|
||||
>
|
||||
<CardContent className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
{/* Sélection utilisateur */}
|
||||
{isAdmin && (
|
||||
@ -1969,7 +1942,7 @@ const WeeklySummaryPage = ({ userId }) => {
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</MainCard>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user