This commit is contained in:
Louis Mazin 2026-09-19 22:13:02 +02:00
parent bb0f242dd5
commit ba76763c24
6 changed files with 467 additions and 300 deletions

View File

@ -26,7 +26,6 @@ const AdminUsersPage = () => {
const [isCopied, setIsCopied] = useState(false); const [isCopied, setIsCopied] = useState(false);
const [editingUser, setEditingUser] = useState(null); const [editingUser, setEditingUser] = useState(null);
const [editForm, setEditForm] = useState({ const [editForm, setEditForm] = useState({
username: '',
first_name: '', first_name: '',
last_name: '', last_name: '',
email: '', email: '',
@ -134,7 +133,6 @@ const AdminUsersPage = () => {
setUsers(updatedUsers); setUsers(updatedUsers);
setEditingUser(null); setEditingUser(null);
setEditForm({ setEditForm({
username: '',
first_name: '', first_name: '',
last_name: '', last_name: '',
email: '', email: '',
@ -152,7 +150,7 @@ const AdminUsersPage = () => {
if (loading) { if (loading) {
return ( return (
<Card className="page-container page-layout admin-page"> <Card className="page-container page-layout admin-page">
<CardHeader> <CardHeader className="border-b">
<CardTitle>Gestion des comptes</CardTitle> <CardTitle>Gestion des comptes</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@ -164,7 +162,7 @@ const AdminUsersPage = () => {
return ( return (
<Card className="page-container page-layout admin-page flex h-full w-full"> <Card className="page-container page-layout admin-page flex h-full w-full">
<CardHeader> <CardHeader className="border-b">
<CardTitle>Gestion des comptes</CardTitle> <CardTitle>Gestion des comptes</CardTitle>
</CardHeader> </CardHeader>
@ -173,13 +171,13 @@ const AdminUsersPage = () => {
<Alert variant="destructive" className="mb-4"> <Alert variant="destructive" className="mb-4">
<AlertDescription>{error}</AlertDescription> <AlertDescription>{error}</AlertDescription>
</Alert> </Alert>
)} )}
<div className="overflow-x-auto mb-6"> <div className="overflow-x-auto mb-6">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Nom d'utilisateur</TableHead> <TableHead>Nom</TableHead>
<TableHead>Couleur</TableHead> <TableHead>Couleur</TableHead>
<TableHead>Rôle</TableHead> <TableHead>Rôle</TableHead>
<TableHead>Inscrit le</TableHead> <TableHead>Inscrit le</TableHead>
@ -189,7 +187,7 @@ const AdminUsersPage = () => {
<TableBody> <TableBody>
{users.map((user) => ( {users.map((user) => (
<TableRow key={user.id}> <TableRow key={user.id}>
<TableCell>{user.username}</TableCell> <TableCell>{user.first_name} {user.last_name}</TableCell>
<TableCell> <TableCell>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: user.color || '#0000ff' }}></div> <div className="w-4 h-4 rounded-full" style={{ backgroundColor: user.color || '#0000ff' }}></div>
@ -210,7 +208,6 @@ const AdminUsersPage = () => {
} else { } else {
setEditingUser(user.id); setEditingUser(user.id);
setEditForm({ setEditForm({
username: user.username || '',
first_name: user.first_name || '', first_name: user.first_name || '',
last_name: user.last_name || '', last_name: user.last_name || '',
email: user.email || '', email: user.email || '',
@ -227,18 +224,10 @@ const AdminUsersPage = () => {
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader className="border-b">
<DialogTitle>Modifier l'utilisateur</DialogTitle> <DialogTitle>Modifier l'utilisateur</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor={`username-${user.id}`}>Nom d'utilisateur</Label>
<Input
id={`username-${user.id}`}
value={editForm.username}
onChange={(event) => setEditForm({ ...editForm, username: event.target.value })}
/>
</div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor={`first-name-${user.id}`}>Prénom</Label> <Label htmlFor={`first-name-${user.id}`}>Prénom</Label>

View File

@ -10,7 +10,7 @@ const HomePage = () => {
return ( return (
<div className="flex h-full w-full"> <div className="flex h-full w-full">
<Card className="mx-auto h-full w-full"> <Card className="mx-auto h-full w-full">
<CardHeader> <CardHeader className="border-b">
<CardTitle>Profil Utilisateur</CardTitle> <CardTitle>Profil Utilisateur</CardTitle>
</CardHeader> </CardHeader>

View File

@ -32,7 +32,7 @@ const LoginPage = () => {
return ( return (
<div className="auth-page page-layout flex h-full w-full"> <div className="auth-page page-layout flex h-full w-full">
<Card className="flex h-full w-full"> <Card className="flex h-full w-full">
<CardHeader> <CardHeader className="border-b">
<CardTitle>Connexion</CardTitle> <CardTitle>Connexion</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>

View File

@ -47,7 +47,7 @@ const RegisterPage = () => {
return ( return (
<div className="auth-page page-layout flex h-full w-full"> <div className="auth-page page-layout flex h-full w-full">
<Card className="flex h-full w-full"> <Card className="flex h-full w-full">
<CardHeader> <CardHeader className="border-b">
<CardTitle>Lien invalide</CardTitle> <CardTitle>Lien invalide</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@ -62,7 +62,7 @@ const RegisterPage = () => {
return ( return (
<div className="auth-page page-layout flex h-full w-full"> <div className="auth-page page-layout flex h-full w-full">
<Card className="flex h-full w-full"> <Card className="flex h-full w-full">
<CardHeader> <CardHeader className="border-b">
<CardTitle>Compte créé</CardTitle> <CardTitle>Compte créé</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@ -79,7 +79,7 @@ const RegisterPage = () => {
return ( return (
<div className="auth-page page-layout"> <div className="auth-page page-layout">
<Card className="w-full max-w-md mx-auto"> <Card className="w-full max-w-md mx-auto">
<CardHeader> <CardHeader className="border-b">
<CardTitle>Créer un compte</CardTitle> <CardTitle>Créer un compte</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>

View File

@ -10,7 +10,203 @@ import { Alert, AlertDescription } from '@/components/ui/alert';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import ConfirmDialog from '@/components/ConfirmDialog'; import ConfirmDialog from '@/components/ConfirmDialog';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Calendar } from '@/components/ui/calendar';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { CalendarDays, Clock } from 'lucide-react';
const parseDateValue = (value) => {
if (!value) return undefined;
const [year, month, day] = value.split('-').map(Number);
if (!year || !month || !day) return undefined;
return new Date(year, month - 1, day);
};
const formatDateValue = (date) => {
if (!date) return '';
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
const formatDateLabel = (value) => {
const date = parseDateValue(value);
if (!date) return 'Choisir une date';
return date.toLocaleDateString('fr-FR', {
day: '2-digit',
month: 'long',
year: 'numeric',
});
};
const DatePicker = ({
value,
onChange,
min,
placeholder = 'Choisir une date',
}) => {
const [open, setOpen] = useState(false);
const selectedDate = parseDateValue(value);
const minDate = parseDateValue(min);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={
<Button
type="button"
variant="outline"
className="w-full justify-start gap-2 font-normal"
/>
}
>
<CalendarDays className="size-4 text-muted-foreground" />
<span className={selectedDate ? '' : 'text-muted-foreground'}>
{selectedDate ? formatDateLabel(value) : placeholder}
</span>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={selectedDate}
defaultMonth={selectedDate || minDate || new Date()}
disabled={minDate ? { before: minDate } : undefined}
onSelect={(date) => {
if (!date) return;
onChange(formatDateValue(date));
setOpen(false);
}}
/>
</PopoverContent>
</Popover>
);
};
const TimePicker = ({
value,
onChange,
placeholder = 'Choisir une heure',
}) => {
const [open, setOpen] = useState(false);
const [hours = '', minutes = ''] = value ? value.split(':') : [];
const setTimePart = (part, nextValue) => {
const nextHours = part === 'hours' ? nextValue : hours || '00';
const nextMinutes = part === 'minutes' ? nextValue : minutes || '00';
onChange(`${nextHours}:${nextMinutes}`);
};
const hourOptions = Array.from({ length: 24 }, (_, index) =>
String(index).padStart(2, '0')
);
const minuteOptions = Array.from({ length: 60 }, (_, index) =>
String(index).padStart(2, '0')
);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={
<Button
type="button"
variant="outline"
className="w-full justify-start gap-2 font-normal"
/>
}
>
<Clock className="size-4 text-muted-foreground" />
<span className={value ? '' : 'text-muted-foreground'}>
{value || placeholder}
</span>
</PopoverTrigger>
<PopoverContent className="w-[280px] p-3" align="start">
<div className="mb-3 flex items-center justify-between border-b pb-3">
<span className="text-sm font-medium">Choisir une heure</span>
<span className="rounded-md bg-muted px-2 py-1 text-sm font-medium tabular-nums">
{value || '--:--'}
</span>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<p className="mb-2 text-xs font-medium text-muted-foreground">
Heure
</p>
<div className="grid max-h-56 grid-cols-4 gap-1 overflow-y-auto pr-1">
{hourOptions.map((hour) => (
<Button
key={hour}
type="button"
size="sm"
variant={hours === hour ? 'default' : 'ghost'}
className="h-8 px-0 tabular-nums"
onClick={() => setTimePart('hours', hour)}
>
{hour}
</Button>
))}
</div>
</div>
<div>
<p className="mb-2 text-xs font-medium text-muted-foreground">
Minutes
</p>
<div className="grid max-h-56 grid-cols-4 gap-1 overflow-y-auto pr-1">
{minuteOptions.map((minute) => (
<Button
key={minute}
type="button"
size="sm"
variant={minutes === minute ? 'default' : 'ghost'}
className="h-8 px-0 tabular-nums"
onClick={() => setTimePart('minutes', minute)}
>
{minute}
</Button>
))}
</div>
</div>
</div>
<div className="mt-3 border-t pt-3">
<Button
type="button"
variant="secondary"
className="w-full"
disabled={!value}
onClick={() => {
onChange('');
setOpen(false);
}}
>
Effacer
</Button>
</div>
</PopoverContent>
</Popover>
);
};
const TimeClockPage = () => { const TimeClockPage = () => {
const videoRef = useRef(null); const videoRef = useRef(null);
@ -23,6 +219,8 @@ const TimeClockPage = () => {
const [timeEntries, setTimeEntries] = useState([]); const [timeEntries, setTimeEntries] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [isScannerDialogOpen, setIsScannerDialogOpen] = useState(false); const [isScannerDialogOpen, setIsScannerDialogOpen] = useState(false);
const [cameraPermissionError, setCameraPermissionError] = useState(false);
const scannerSessionRef = useRef(0);
const [newEntry, setNewEntry] = useState({ const [newEntry, setNewEntry] = useState({
date: new Date().toISOString().split('T')[0], date: new Date().toISOString().split('T')[0],
end_date: new Date().toISOString().split('T')[0], end_date: new Date().toISOString().split('T')[0],
@ -150,35 +348,21 @@ const TimeClockPage = () => {
} }
}; };
const scannerSessionRef = useRef(0);
const stopScanner = () => { const stopScanner = () => {
// Invalide immédiatement toute session de scan en cours
scannerSessionRef.current += 1; scannerSessionRef.current += 1;
// Arrêter les contrôles ZXing
if (qrControlsRef.current) { if (qrControlsRef.current) {
try { try {
qrControlsRef.current.stop(); qrControlsRef.current.stop();
} catch (error) { } catch (stopError) {
console.warn('Impossible d’arrêter le scanner QR.', error); console.warn('Arrêt du lecteur QR déjà effectué.', stopError);
} }
qrControlsRef.current = null; qrControlsRef.current = null;
} }
// Arrêter explicitement la caméra
if (videoRef.current?.srcObject) { if (videoRef.current?.srcObject) {
const stream = videoRef.current.srcObject; const stream = videoRef.current.srcObject;
stream.getTracks().forEach((track) => track.stop());
stream.getTracks().forEach((track) => {
try {
track.stop();
} catch (error) {
console.warn('Impossible d’arrêter la piste caméra.', error);
}
});
videoRef.current.srcObject = null; videoRef.current.srcObject = null;
} }
}; };
@ -189,15 +373,10 @@ const TimeClockPage = () => {
}; };
const handleQrResult = async (code, sessionId) => { const handleQrResult = async (code, sessionId) => {
// Si cette session n’est plus active, on ignore le résultat if (sessionId !== scannerSessionRef.current) return;
if (scannerSessionRef.current !== sessionId) {
return;
}
// Fermer immédiatement la caméra
closeScanner();
setScanConflict(null); setScanConflict(null);
closeScanner();
try { try {
const response = await timeApi.scanTimeEntry(code); const response = await timeApi.scanTimeEntry(code);
@ -218,15 +397,12 @@ const TimeClockPage = () => {
const resolveScanConflict = async (resolution) => { const resolveScanConflict = async (resolution) => {
if (!scanConflict) return; if (!scanConflict) return;
setError(''); setError('');
try { try {
const response = await timeApi.scanTimeEntry(scanConflict.code, { const response = await timeApi.scanTimeEntry(scanConflict.code, {
resolution, resolution,
time: conflictTime, time: conflictTime,
}); });
setScanMessage(response.message); setScanMessage(response.message);
setScanConflict(null); setScanConflict(null);
await loadTimeEntries(); await loadTimeEntries();
@ -235,35 +411,29 @@ const TimeClockPage = () => {
} }
}; };
// Nettoyage lors du démontage
useEffect(() => { useEffect(() => {
return () => { return () => {
stopScanner(); stopScanner();
}; };
}, []); }, []);
// Gestion du scanner QR
useEffect(() => { useEffect(() => {
if (!isScannerDialogOpen) { if (!isScannerDialogOpen) return undefined;
return;
}
const sessionId = scannerSessionRef.current; const sessionId = scannerSessionRef.current;
let cancelled = false; let cancelled = false;
let reader = null; let reader = null;
const startReader = async () => { const startReader = async () => {
// Attendre que DialogContent soit réellement monté
await new Promise((resolve) => { await new Promise((resolve) => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
requestAnimationFrame(resolve); requestAnimationFrame(resolve);
}); });
}); });
// Le dialog a pu être fermé pendant l’attente
if ( if (
cancelled || cancelled ||
scannerSessionRef.current !== sessionId || sessionId !== scannerSessionRef.current ||
!videoRef.current !videoRef.current
) { ) {
return; return;
@ -284,29 +454,25 @@ const TimeClockPage = () => {
if ( if (
!result || !result ||
cancelled || cancelled ||
scannerSessionRef.current !== sessionId sessionId !== scannerSessionRef.current
) { ) {
return; return;
} }
handleQrResult(result.getText(), sessionId); handleQrResult(result.getText(), sessionId);
} },
); );
// La fermeture peut avoir eu lieu pendant l’initialisation
if ( if (
cancelled || cancelled ||
scannerSessionRef.current !== sessionId sessionId !== scannerSessionRef.current ||
!isScannerDialogOpen
) { ) {
try { try {
controls.stop(); controls.stop();
} catch (error) { } catch (stopError) {
console.warn( console.warn('Arrêt du scanner ignoré.', stopError);
'Impossible d’arrêter le scanner après annulation.',
error
);
} }
return; return;
} }
@ -314,16 +480,28 @@ const TimeClockPage = () => {
} catch (scannerError) { } catch (scannerError) {
if ( if (
cancelled || cancelled ||
scannerSessionRef.current !== sessionId sessionId !== scannerSessionRef.current
) { ) {
return; return;
} }
console.error('Erreur scanner QR:', scannerError); console.error('Erreur scanner QR:', scannerError);
setError( const permissionDenied =
'Impossible d’accéder à la caméra. Vérifiez les permissions et utilisez HTTPS sur le réseau.' scannerError?.name === 'NotAllowedError' ||
); scannerError?.name === 'PermissionDeniedError';
if (permissionDenied) {
setCameraPermissionError(true);
setError(
'L’accès à la caméra a été refusé. Autorisez la caméra dans les paramètres du site puis réessayez.',
);
} else {
setCameraPermissionError(false);
setError(
'Impossible d’accéder à la caméra. Vérifiez les permissions et utilisez HTTPS sur le réseau.',
);
}
closeScanner(); closeScanner();
} }
@ -334,50 +512,17 @@ const TimeClockPage = () => {
return () => { return () => {
cancelled = true; cancelled = true;
// Invalide cette session
if (scannerSessionRef.current === sessionId) {
scannerSessionRef.current += 1;
}
try { try {
reader?.reset(); reader?.reset();
} catch (error) { } catch (resetError) {
console.warn( console.warn(
'Réinitialisation du lecteur QR ignorée.', 'Réinitialisation du lecteur QR ignorée.',
error resetError,
); );
} }
// Arrêter les contrôles ZXing if (sessionId === scannerSessionRef.current) {
if (qrControlsRef.current) { stopScanner();
try {
qrControlsRef.current.stop();
} catch (error) {
console.warn(
'Impossible d’arrêter les contrôles QR.',
error
);
}
qrControlsRef.current = null;
}
// Arrêter explicitement toutes les pistes caméra
if (videoRef.current?.srcObject) {
const stream = videoRef.current.srcObject;
stream.getTracks().forEach((track) => {
try {
track.stop();
} catch (error) {
console.warn(
'Impossible d’arrêter la piste caméra.',
error
);
}
});
videoRef.current.srcObject = null;
} }
}; };
}, [isScannerDialogOpen]); }, [isScannerDialogOpen]);
@ -386,6 +531,7 @@ const TimeClockPage = () => {
setError(''); setError('');
setScanMessage(''); setScanMessage('');
setScanConflict(null); setScanConflict(null);
setCameraPermissionError(false);
if ( if (
!window.isSecureContext && !window.isSecureContext &&
@ -393,7 +539,7 @@ const TimeClockPage = () => {
window.location.hostname !== '192.168.1.19' window.location.hostname !== '192.168.1.19'
) { ) {
setError( setError(
'La caméra nécessite une connexion HTTPS sur cette adresse réseau.' 'La caméra nécessite une connexion HTTPS sur cette adresse réseau.',
); );
return; return;
} }
@ -403,12 +549,62 @@ const TimeClockPage = () => {
return; return;
} }
// Nouvelle session de scanner
scannerSessionRef.current += 1; scannerSessionRef.current += 1;
setIsScannerDialogOpen(true); setIsScannerDialogOpen(true);
}; };
const requestCameraPermission = async () => {
setError('');
if (
!window.isSecureContext &&
window.location.hostname !== 'localhost' &&
window.location.hostname !== '192.168.1.19'
) {
setError(
'La caméra nécessite une connexion HTTPS sur cette adresse réseau.',
);
return;
}
if (!navigator.mediaDevices?.getUserMedia) {
setError('Ce navigateur ne permet pas l’accès à la caméra.');
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: { ideal: 'environment' },
},
audio: false,
});
stream.getTracks().forEach((track) => track.stop());
setCameraPermissionError(false);
startScanner();
} catch (permissionError) {
console.error(
'Erreur lors de la demande de permission caméra:',
permissionError,
);
if (
permissionError?.name === 'NotAllowedError' ||
permissionError?.name === 'PermissionDeniedError'
) {
setCameraPermissionError(true);
setError(
'La caméra est toujours refusée. Autorisez-la dans les paramètres du site, puis cliquez à nouveau sur « Autoriser la caméra ».',
);
} else {
setError(
'Impossible d’obtenir l’accès à la caméra. Vérifiez les permissions du navigateur et la connexion HTTPS.',
);
}
}
};
const formatTime = (timeString) => { const formatTime = (timeString) => {
if (!timeString) return '00h00'; if (!timeString) return '00h00';
const [hours, minutes] = timeString.split(':'); const [hours, minutes] = timeString.split(':');
@ -456,7 +652,7 @@ const TimeClockPage = () => {
return ( return (
<Card className="flex h-full w-full"> <Card className="flex h-full w-full">
<CardHeader> <CardHeader className="border-b">
<CardTitle>Pointage</CardTitle> <CardTitle>Pointage</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@ -466,54 +662,16 @@ const TimeClockPage = () => {
</Alert> </Alert>
)} )}
{/* Dialog for QR scanner preview */} {cameraPermissionError && (
<Dialog <div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center">
open={isScannerDialogOpen} <Button type="button" variant="secondary" onClick={requestCameraPermission}>
onOpenChange={(open) => { Autoriser la caméra
if (open) { </Button>
setIsScannerDialogOpen(true); <p className="text-sm text-muted-foreground">
} else { Si le navigateur a déjà bloqué la caméra, autorisez-la dans les paramètres du site puis cliquez ici pour réessayer.
closeScanner(); </p>
} </div>
}} )}
>
<Button
onClick={startScanner}
variant="default"
className="mb-4"
>
{isAdmin ? 'Pointer avec un QR code' : 'Pointer'}
</Button>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Aperçu de la caméra</DialogTitle>
</DialogHeader>
<div className="scanner-panel">
<video
ref={videoRef}
className="scanner-video h-auto w-full"
playsInline
muted
autoPlay
/>
<div
className="scanner-frame"
aria-hidden="true"
/>
<Button
onClick={closeScanner}
variant="secondary"
className="mt-2"
>
Annuler
</Button>
</div>
</DialogContent>
</Dialog>
{scanMessage && ( {scanMessage && (
<Alert variant="success" className="mb-4"> <Alert variant="success" className="mb-4">
@ -523,18 +681,15 @@ const TimeClockPage = () => {
{scanConflict && ( {scanConflict && (
<Card className="mb-4"> <Card className="mb-4">
<CardHeader> <CardHeader className="border-b">
<CardTitle>Conflit de pointage</CardTitle> <CardTitle>Conflit de pointage</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="mb-2">{scanConflict.message}</p> <p className="mb-2">{scanConflict.message}</p>
<Label htmlFor="conflict-time">Heure à utiliser</Label> <Label htmlFor="conflict-time">Heure à utiliser</Label>
<Input <TimePicker
id="conflict-time"
type="time"
value={conflictTime} value={conflictTime}
onChange={(event) => setConflictTime(event.target.value)} onChange={setConflictTime}
className="mb-2"
/> />
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
@ -555,10 +710,10 @@ const TimeClockPage = () => {
)} )}
{/* Time tracking section */} {/* Time tracking section */}
<div className={isAdmin ? 'admin-time-layout' : undefined}> <div className="w-full">
{isAdmin && ( {isAdmin && (
<Card className="mb-4"> <Card className="mb-4">
<CardHeader> <CardHeader className="border-b">
<CardTitle> <CardTitle>
{editingEntry ? 'Modifier le pointage' : 'Ajouter un pointage manuel'} {editingEntry ? 'Modifier le pointage' : 'Ajouter un pointage manuel'}
</CardTitle> </CardTitle>
@ -566,75 +721,69 @@ const TimeClockPage = () => {
<CardContent> <CardContent>
{editingEntry ? ( {editingEntry ? (
<form onSubmit={handleUpdateEntry}> <form onSubmit={handleUpdateEntry}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4"> <div className="mb-4 grid grid-cols-1 gap-4 md:grid-cols-2">
<div> <div>
<Label htmlFor="edit-start-time">Heure d'entrée :</Label> <Label htmlFor="edit-start-time">Heure d'entrée :</Label>
<Input <TimePicker
id="edit-start-time"
type="time"
value={editForm.start_time} value={editForm.start_time}
onChange={(e) => setEditForm({ ...editForm, start_time: e.target.value })} onChange={(value) =>
setEditForm({ ...editForm, start_time: value })
}
/> />
</div> </div>
<div> <div>
<Label htmlFor="edit-end-time">Heure de sortie :</Label> <Label htmlFor="edit-end-time">Heure de sortie :</Label>
<Input <TimePicker
id="edit-end-time"
type="time"
value={editForm.end_time} value={editForm.end_time}
onChange={(e) => setEditForm({ ...editForm, end_time: e.target.value })} onChange={(value) =>
setEditForm({ ...editForm, end_time: value })
}
/> />
</div> </div>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button type="submit">Enregistrer</Button> <Button type="submit">Enregistrer</Button>
<Button <Button type="button" variant="secondary" onClick={() => setEditingEntry(null)}>Annuler</Button>
type="button"
variant="secondary"
onClick={() => setEditingEntry(null)}
>
Annuler
</Button>
</div> </div>
</form> </form>
) : ( ) : (
<form onSubmit={handleAddEntry}> <form onSubmit={handleAddEntry}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4"> <div className="mb-4 grid grid-cols-1 gap-4 md:grid-cols-2">
<div> <div>
<Label>Date:</Label> <Label>Date:</Label>
<Input <DatePicker
type="date"
value={newEntry.date} value={newEntry.date}
onChange={(e) => setNewEntry({...newEntry, date: e.target.value})} onChange={(value) =>
required setNewEntry({ ...newEntry, date: value })
}
/> />
</div> </div>
<div> <div>
<Label>Date de sortie:</Label> <Label>Date de sortie:</Label>
<Input <DatePicker
type="date"
value={newEntry.end_date} value={newEntry.end_date}
min={newEntry.date} min={newEntry.date}
onChange={(e) => setNewEntry({...newEntry, end_date: e.target.value})} onChange={(value) =>
required setNewEntry({ ...newEntry, end_date: value })
}
/> />
</div> </div>
<div> <div>
<Label>Heure d'entrée:</Label> <Label>Heure d'entrée:</Label>
<Input <TimePicker
type="time"
value={newEntry.start_time} value={newEntry.start_time}
onChange={(e) => setNewEntry({...newEntry, start_time: e.target.value})} onChange={(value) =>
required setNewEntry({ ...newEntry, start_time: value })
}
/> />
</div> </div>
<div> <div>
<Label>Heure de sortie:</Label> <Label>Heure de sortie:</Label>
<Input <TimePicker
type="time"
value={newEntry.end_time} value={newEntry.end_time}
onChange={(e) => setNewEntry({...newEntry, end_time: e.target.value})} onChange={(value) =>
required setNewEntry({ ...newEntry, end_time: value })
}
/> />
</div> </div>
</div> </div>
@ -645,73 +794,102 @@ const TimeClockPage = () => {
</Card> </Card>
)} )}
{/* Time entries list */} <div className="w-full">
<Card> {/* Pointage uniquement disponible sur mobile */}
<CardHeader> <div className="flex flex-col items-center justify-center py-6 md:hidden">
<CardTitle>Historique des pointages</CardTitle> <Dialog
</CardHeader> open={isScannerDialogOpen}
<CardContent> onOpenChange={(open) => {
{loading ? ( if (open) {
<p>Chargement des pointages...</p> setIsScannerDialogOpen(true);
) : timeEntries.length === 0 ? ( } else {
<p>Aucun pointage enregistré</p> closeScanner();
) : ( }
<div className="overflow-x-auto"> }}
<Table> >
<TableHeader> <Button onClick={startScanner} variant="default">
<TableRow> {isAdmin ? 'Pointer avec un QR code' : 'Pointer'}
{isAdmin && <TableHead>Utilisateur</TableHead>} </Button>
<TableHead>Date</TableHead>
<TableHead>Entrée</TableHead> <DialogContent className="max-w-2xl">
<TableHead>Sortie</TableHead> <DialogHeader className="border-b">
<TableHead>Durée</TableHead> <DialogTitle>Aperçu de la caméra</DialogTitle>
{isAdmin && <TableHead>Actions</TableHead>} </DialogHeader>
</TableRow> <div className="scanner-panel">
</TableHeader> <video
<TableBody> ref={videoRef}
{getDisplayTimeEntries().map((entry) => ( className="scanner-video h-auto w-full"
<TableRow key={entry.id}> playsInline
{isAdmin && ( muted
<TableCell>{entry.user}</TableCell> autoPlay
)} />
<TableCell>{formatDate(entry.date)}</TableCell> <div className="scanner-frame" aria-hidden="true" />
<TableCell>{formatTime(entry.start_time)}</TableCell> <Button onClick={closeScanner} variant="secondary" className="mt-2">
<TableCell>{formatTime(entry.end_time)}</TableCell> Annuler
<TableCell> </Button>
<Badge variant="secondary"> </div>
{formatDuration(entry.duration)} </DialogContent>
</Badge> </Dialog>
</TableCell> </div>
{isAdmin && (
<TableCell> {/* Sur ordinateur, le pointage est volontairement indisponible */}
<div className="flex gap-2"> {!isAdmin &&
<Button <div className="hidden py-6 text-center md:block">
onClick={() => handleEditEntry(entry)} <p className="text-sm text-muted-foreground">
variant="secondary" Le pointage est indisponible sur ordinateur.
size="sm" </p>
> </div>
Modifier }
</Button>
<ConfirmDialog <Card>
title="Supprimer le pointage" <CardHeader className="border-b">
description="Êtes-vous sûr de vouloir supprimer ce pointage ?" <CardTitle>Historique des pointages</CardTitle>
onConfirm={() => handleDeleteEntry(entry.id)} </CardHeader>
> <CardContent>
<Button variant="destructive" size="sm"> {loading ? (
Supprimer <p>Chargement des pointages...</p>
</Button> ) : timeEntries.length === 0 ? (
</ConfirmDialog> <p>Aucun pointage enregistré</p>
</div> ) : (
</TableCell> <div className="overflow-x-auto">
)} <Table>
<TableHeader>
<TableRow>
{isAdmin && <TableHead>Nom</TableHead>}
<TableHead>Date</TableHead>
<TableHead>Entrée</TableHead>
<TableHead>Sortie</TableHead>
<TableHead>Durée</TableHead>
{isAdmin && <TableHead>Actions</TableHead>}
</TableRow> </TableRow>
))} </TableHeader>
</TableBody> <TableBody>
</Table> {getDisplayTimeEntries().map((entry) => (
</div> <TableRow key={entry.id}>
)} {isAdmin && <TableCell>{entry.first_name} {entry.last_name}</TableCell>}
</CardContent> <TableCell>{formatDate(entry.date)}</TableCell>
</Card> <TableCell>{formatTime(entry.start_time)}</TableCell>
<TableCell>{formatTime(entry.end_time)}</TableCell>
<TableCell><Badge variant="secondary">{formatDuration(entry.duration)}</Badge></TableCell>
{isAdmin && (
<TableCell>
<div className="flex gap-2">
<Button onClick={() => handleEditEntry(entry)} variant="secondary" size="sm">Modifier</Button>
<ConfirmDialog title="Supprimer le pointage" description="Êtes-vous sûr de vouloir supprimer ce pointage ?" onConfirm={() => handleDeleteEntry(entry.id)}>
<Button variant="destructive" size="sm">Supprimer</Button>
</ConfirmDialog>
</div>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@ -189,8 +189,8 @@ const WeeklySummaryPage = ({ userId }) => {
).padStart(2, '0')}`; ).padStart(2, '0')}`;
}; };
const getUserColor = (username) => { const getUserColor = (first_name) => {
const hash = [...username].reduce( const hash = [...first_name].reduce(
(total, character) => total + character.charCodeAt(0), (total, character) => total + character.charCodeAt(0),
0, 0,
); );
@ -256,15 +256,15 @@ const WeeklySummaryPage = ({ userId }) => {
start, start,
end, end,
users: activeAttendances users: activeAttendances
.map(({ attendance }) => attendance.username) .map(({ attendance }) => attendance.first_name)
.filter(Boolean) .filter(Boolean)
.filter( .filter(
(username, usernameIndex, names) => (first_name, first_nameIndex, names) =>
names.indexOf(username) === usernameIndex, names.indexOf(first_name) === first_nameIndex,
) )
.map((username) => ({ .map((first_name) => ({
username, first_name,
color: getUserColor(username), color: getUserColor(first_name),
})), })),
}; };
}) })
@ -283,22 +283,22 @@ const WeeklySummaryPage = ({ userId }) => {
getTimeValue(attendance, 'end_time'), getTimeValue(attendance, 'end_time'),
); );
const username = attendance.username; const first_name = attendance.first_name;
if ( if (
start === null || start === null ||
end === null || end === null ||
end <= start || end <= start ||
!username !first_name
) { ) {
return; return;
} }
const currentColumn = columns.get(username); const currentColumn = columns.get(first_name);
columns.set(username, { columns.set(first_name, {
username, first_name,
color: getUserColor(username), color: getUserColor(first_name),
start: currentColumn start: currentColumn
? Math.min(currentColumn.start, start) ? Math.min(currentColumn.start, start)
: start, : start,
@ -496,16 +496,16 @@ const WeeklySummaryPage = ({ userId }) => {
const legendUsers = [ const legendUsers = [
...new Set( ...new Set(
attendances attendances
.map((attendance) => attendance.username) .map((attendance) => attendance.first_name)
.filter(Boolean), .filter(Boolean),
), ),
] ]
.sort((first, second) => .sort((first, second) =>
first.localeCompare(second, 'fr'), first.localeCompare(second, 'fr'),
) )
.map((username) => ({ .map((first_name) => ({
username, first_name,
color: getUserColor(username), color: getUserColor(first_name),
})); }));
const displayDay = isMobile const displayDay = isMobile
@ -627,9 +627,9 @@ const WeeklySummaryPage = ({ userId }) => {
</span> </span>
{legendUsers.map( {legendUsers.map(
({ username, color }) => ( ({ first_name, color }) => (
<div <div
key={username} key={first_name}
className="flex items-center gap-1" className="flex items-center gap-1"
> >
<div <div
@ -640,7 +640,7 @@ const WeeklySummaryPage = ({ userId }) => {
/> />
<span className="text-sm"> <span className="text-sm">
{username} {first_name}
</span> </span>
</div> </div>
), ),
@ -715,7 +715,7 @@ const WeeklySummaryPage = ({ userId }) => {
({ ({
start, start,
end, end,
username, first_name,
color, color,
}, columnIndex) => { }, columnIndex) => {
const style = const style =
@ -731,7 +731,7 @@ const WeeklySummaryPage = ({ userId }) => {
<div <div
key={`${formatDateKey( key={`${formatDateKey(
displayDay, displayDay,
)}-${username}`} )}-${first_name}`}
className="absolute rounded-sm" className="absolute rounded-sm"
style={{ style={{
...style, ...style,
@ -743,7 +743,7 @@ const WeeklySummaryPage = ({ userId }) => {
backgroundColor: backgroundColor:
color, color,
}} }}
title={`${username} : ${formatSegmentTime( title={`${first_name} : ${formatSegmentTime(
start, start,
)} - ${formatSegmentTime( )} - ${formatSegmentTime(
end, end,
@ -775,8 +775,8 @@ const WeeklySummaryPage = ({ userId }) => {
users users
.map( .map(
({ ({
username, first_name,
}) => username, }) => first_name,
) )
.join(', '); .join(', ');
@ -890,7 +890,7 @@ const WeeklySummaryPage = ({ userId }) => {
({ ({
start, start,
end, end,
username, first_name,
color, color,
}) => { }) => {
const style = const style =
@ -906,14 +906,14 @@ const WeeklySummaryPage = ({ userId }) => {
<div <div
key={`${formatDateKey( key={`${formatDateKey(
day, day,
)}-${username}`} )}-${first_name}`}
className="absolute left-2 right-2 rounded-md border" className="absolute left-2 right-2 rounded-md border"
style={{ style={{
...style, ...style,
backgroundColor: `${color}22`, backgroundColor: `${color}22`,
borderColor: color, borderColor: color,
}} }}
title={`${username} : ${formatSegmentTime( title={`${first_name} : ${formatSegmentTime(
start, start,
)} - ${formatSegmentTime( )} - ${formatSegmentTime(
end, end,
@ -934,7 +934,7 @@ const WeeklySummaryPage = ({ userId }) => {
color, color,
}} }}
> >
{username} {first_name}
</div> </div>
<div className="text-muted-foreground"> <div className="text-muted-foreground">
@ -972,9 +972,9 @@ const WeeklySummaryPage = ({ userId }) => {
users users
.map( .map(
({ ({
username, first_name,
}) => }) =>
username, first_name,
) )
.join(', '); .join(', ');