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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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,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>
);
};

View File

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

View File

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

View File

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

View File

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