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

View File

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

View File

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

View File

@ -47,7 +47,7 @@ const RegisterPage = () => {
return (
<div className="auth-page page-layout flex h-full w-full">
<Card className="flex h-full w-full">
<CardHeader>
<CardHeader className="border-b">
<CardTitle>Lien invalide</CardTitle>
</CardHeader>
<CardContent>
@ -62,7 +62,7 @@ const RegisterPage = () => {
return (
<div className="auth-page page-layout flex h-full w-full">
<Card className="flex h-full w-full">
<CardHeader>
<CardHeader className="border-b">
<CardTitle>Compte créé</CardTitle>
</CardHeader>
<CardContent>
@ -79,7 +79,7 @@ const RegisterPage = () => {
return (
<div className="auth-page page-layout">
<Card className="w-full max-w-md mx-auto">
<CardHeader>
<CardHeader className="border-b">
<CardTitle>Créer un compte</CardTitle>
</CardHeader>
<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 { Badge } from '@/components/ui/badge';
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 videoRef = useRef(null);
@ -23,6 +219,8 @@ const TimeClockPage = () => {
const [timeEntries, setTimeEntries] = useState([]);
const [loading, setLoading] = useState(true);
const [isScannerDialogOpen, setIsScannerDialogOpen] = useState(false);
const [cameraPermissionError, setCameraPermissionError] = useState(false);
const scannerSessionRef = useRef(0);
const [newEntry, setNewEntry] = useState({
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 = () => {
// Invalide immédiatement toute session de scan en cours
scannerSessionRef.current += 1;
// Arrêter les contrôles ZXing
if (qrControlsRef.current) {
try {
qrControlsRef.current.stop();
} catch (error) {
console.warn('Impossible d’arrêter le scanner QR.', error);
} catch (stopError) {
console.warn('Arrêt du lecteur QR déjà effectué.', stopError);
}
qrControlsRef.current = null;
}
// Arrêter explicitement la 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);
}
});
stream.getTracks().forEach((track) => track.stop());
videoRef.current.srcObject = null;
}
};
@ -189,15 +373,10 @@ const TimeClockPage = () => {
};
const handleQrResult = async (code, sessionId) => {
// Si cette session n’est plus active, on ignore le résultat
if (scannerSessionRef.current !== sessionId) {
return;
}
// Fermer immédiatement la caméra
closeScanner();
if (sessionId !== scannerSessionRef.current) return;
setScanConflict(null);
closeScanner();
try {
const response = await timeApi.scanTimeEntry(code);
@ -218,15 +397,12 @@ const TimeClockPage = () => {
const resolveScanConflict = async (resolution) => {
if (!scanConflict) return;
setError('');
try {
const response = await timeApi.scanTimeEntry(scanConflict.code, {
resolution,
time: conflictTime,
});
setScanMessage(response.message);
setScanConflict(null);
await loadTimeEntries();
@ -235,35 +411,29 @@ const TimeClockPage = () => {
}
};
// Nettoyage lors du démontage
useEffect(() => {
return () => {
stopScanner();
};
}, []);
// Gestion du scanner QR
useEffect(() => {
if (!isScannerDialogOpen) {
return;
}
if (!isScannerDialogOpen) return undefined;
const sessionId = scannerSessionRef.current;
let cancelled = false;
let reader = null;
const startReader = async () => {
// Attendre que DialogContent soit réellement monté
await new Promise((resolve) => {
requestAnimationFrame(() => {
requestAnimationFrame(resolve);
});
});
// Le dialog a pu être fermé pendant l’attente
if (
cancelled ||
scannerSessionRef.current !== sessionId ||
sessionId !== scannerSessionRef.current ||
!videoRef.current
) {
return;
@ -284,29 +454,25 @@ const TimeClockPage = () => {
if (
!result ||
cancelled ||
scannerSessionRef.current !== sessionId
sessionId !== scannerSessionRef.current
) {
return;
}
handleQrResult(result.getText(), sessionId);
}
},
);
// La fermeture peut avoir eu lieu pendant l’initialisation
if (
cancelled ||
scannerSessionRef.current !== sessionId
sessionId !== scannerSessionRef.current ||
!isScannerDialogOpen
) {
try {
controls.stop();
} catch (error) {
console.warn(
'Impossible d’arrêter le scanner après annulation.',
error
);
} catch (stopError) {
console.warn('Arrêt du scanner ignoré.', stopError);
}
return;
}
@ -314,16 +480,28 @@ const TimeClockPage = () => {
} catch (scannerError) {
if (
cancelled ||
scannerSessionRef.current !== sessionId
sessionId !== scannerSessionRef.current
) {
return;
}
console.error('Erreur scanner QR:', scannerError);
setError(
'Impossible d’accéder à la caméra. Vérifiez les permissions et utilisez HTTPS sur le réseau.'
);
const permissionDenied =
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();
}
@ -334,50 +512,17 @@ const TimeClockPage = () => {
return () => {
cancelled = true;
// Invalide cette session
if (scannerSessionRef.current === sessionId) {
scannerSessionRef.current += 1;
}
try {
reader?.reset();
} catch (error) {
} catch (resetError) {
console.warn(
'Réinitialisation du lecteur QR ignorée.',
error
resetError,
);
}
// Arrêter les contrôles ZXing
if (qrControlsRef.current) {
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;
if (sessionId === scannerSessionRef.current) {
stopScanner();
}
};
}, [isScannerDialogOpen]);
@ -386,6 +531,7 @@ const TimeClockPage = () => {
setError('');
setScanMessage('');
setScanConflict(null);
setCameraPermissionError(false);
if (
!window.isSecureContext &&
@ -393,7 +539,7 @@ const TimeClockPage = () => {
window.location.hostname !== '192.168.1.19'
) {
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;
}
@ -403,12 +549,62 @@ const TimeClockPage = () => {
return;
}
// Nouvelle session de scanner
scannerSessionRef.current += 1;
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) => {
if (!timeString) return '00h00';
const [hours, minutes] = timeString.split(':');
@ -456,7 +652,7 @@ const TimeClockPage = () => {
return (
<Card className="flex h-full w-full">
<CardHeader>
<CardHeader className="border-b">
<CardTitle>Pointage</CardTitle>
</CardHeader>
<CardContent>
@ -466,54 +662,16 @@ const TimeClockPage = () => {
</Alert>
)}
{/* Dialog for QR scanner preview */}
<Dialog
open={isScannerDialogOpen}
onOpenChange={(open) => {
if (open) {
setIsScannerDialogOpen(true);
} else {
closeScanner();
}
}}
>
<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>
{cameraPermissionError && (
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center">
<Button type="button" variant="secondary" onClick={requestCameraPermission}>
Autoriser la caméra
</Button>
<p className="text-sm text-muted-foreground">
Si le navigateur a déjà bloqué la caméra, autorisez-la dans les paramètres du site puis cliquez ici pour réessayer.
</p>
</div>
)}
{scanMessage && (
<Alert variant="success" className="mb-4">
@ -523,18 +681,15 @@ const TimeClockPage = () => {
{scanConflict && (
<Card className="mb-4">
<CardHeader>
<CardHeader className="border-b">
<CardTitle>Conflit de pointage</CardTitle>
</CardHeader>
<CardContent>
<p className="mb-2">{scanConflict.message}</p>
<Label htmlFor="conflict-time">Heure à utiliser</Label>
<Input
id="conflict-time"
type="time"
<TimePicker
value={conflictTime}
onChange={(event) => setConflictTime(event.target.value)}
className="mb-2"
onChange={setConflictTime}
/>
<div className="flex gap-2">
<Button
@ -555,10 +710,10 @@ const TimeClockPage = () => {
)}
{/* Time tracking section */}
<div className={isAdmin ? 'admin-time-layout' : undefined}>
<div className="w-full">
{isAdmin && (
<Card className="mb-4">
<CardHeader>
<CardHeader className="border-b">
<CardTitle>
{editingEntry ? 'Modifier le pointage' : 'Ajouter un pointage manuel'}
</CardTitle>
@ -566,75 +721,69 @@ const TimeClockPage = () => {
<CardContent>
{editingEntry ? (
<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>
<Label htmlFor="edit-start-time">Heure d'entrée :</Label>
<Input
id="edit-start-time"
type="time"
<TimePicker
value={editForm.start_time}
onChange={(e) => setEditForm({ ...editForm, start_time: e.target.value })}
onChange={(value) =>
setEditForm({ ...editForm, start_time: value })
}
/>
</div>
<div>
<Label htmlFor="edit-end-time">Heure de sortie :</Label>
<Input
id="edit-end-time"
type="time"
<TimePicker
value={editForm.end_time}
onChange={(e) => setEditForm({ ...editForm, end_time: e.target.value })}
onChange={(value) =>
setEditForm({ ...editForm, end_time: value })
}
/>
</div>
</div>
<div className="flex gap-2">
<Button type="submit">Enregistrer</Button>
<Button
type="button"
variant="secondary"
onClick={() => setEditingEntry(null)}
>
Annuler
</Button>
<Button type="button" variant="secondary" onClick={() => setEditingEntry(null)}>Annuler</Button>
</div>
</form>
) : (
<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>
<Label>Date:</Label>
<Input
type="date"
<DatePicker
value={newEntry.date}
onChange={(e) => setNewEntry({...newEntry, date: e.target.value})}
required
onChange={(value) =>
setNewEntry({ ...newEntry, date: value })
}
/>
</div>
<div>
<Label>Date de sortie:</Label>
<Input
type="date"
<DatePicker
value={newEntry.end_date}
min={newEntry.date}
onChange={(e) => setNewEntry({...newEntry, end_date: e.target.value})}
required
onChange={(value) =>
setNewEntry({ ...newEntry, end_date: value })
}
/>
</div>
<div>
<Label>Heure d'entrée:</Label>
<Input
type="time"
<TimePicker
value={newEntry.start_time}
onChange={(e) => setNewEntry({...newEntry, start_time: e.target.value})}
required
onChange={(value) =>
setNewEntry({ ...newEntry, start_time: value })
}
/>
</div>
<div>
<Label>Heure de sortie:</Label>
<Input
type="time"
<TimePicker
value={newEntry.end_time}
onChange={(e) => setNewEntry({...newEntry, end_time: e.target.value})}
required
onChange={(value) =>
setNewEntry({ ...newEntry, end_time: value })
}
/>
</div>
</div>
@ -645,73 +794,102 @@ const TimeClockPage = () => {
</Card>
)}
{/* Time entries list */}
<Card>
<CardHeader>
<CardTitle>Historique des pointages</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<p>Chargement des pointages...</p>
) : timeEntries.length === 0 ? (
<p>Aucun pointage enregistré</p>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
{isAdmin && <TableHead>Utilisateur</TableHead>}
<TableHead>Date</TableHead>
<TableHead>Entrée</TableHead>
<TableHead>Sortie</TableHead>
<TableHead>Durée</TableHead>
{isAdmin && <TableHead>Actions</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{getDisplayTimeEntries().map((entry) => (
<TableRow key={entry.id}>
{isAdmin && (
<TableCell>{entry.user}</TableCell>
)}
<TableCell>{formatDate(entry.date)}</TableCell>
<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>
)}
<div className="w-full">
{/* Pointage uniquement disponible sur mobile */}
<div className="flex flex-col items-center justify-center py-6 md:hidden">
<Dialog
open={isScannerDialogOpen}
onOpenChange={(open) => {
if (open) {
setIsScannerDialogOpen(true);
} else {
closeScanner();
}
}}
>
<Button onClick={startScanner} variant="default">
{isAdmin ? 'Pointer avec un QR code' : 'Pointer'}
</Button>
<DialogContent className="max-w-2xl">
<DialogHeader className="border-b">
<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>
</div>
{/* Sur ordinateur, le pointage est volontairement indisponible */}
{!isAdmin &&
<div className="hidden py-6 text-center md:block">
<p className="text-sm text-muted-foreground">
Le pointage est indisponible sur ordinateur.
</p>
</div>
}
<Card>
<CardHeader className="border-b">
<CardTitle>Historique des pointages</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<p>Chargement des pointages...</p>
) : timeEntries.length === 0 ? (
<p>Aucun pointage enregistré</p>
) : (
<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>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</TableHeader>
<TableBody>
{getDisplayTimeEntries().map((entry) => (
<TableRow key={entry.id}>
{isAdmin && <TableCell>{entry.first_name} {entry.last_name}</TableCell>}
<TableCell>{formatDate(entry.date)}</TableCell>
<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>
</CardContent>
</Card>

View File

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