diff --git a/frontend/src/pages/AdminUsersPage.jsx b/frontend/src/pages/AdminUsersPage.jsx index 562bca9..c730937 100644 --- a/frontend/src/pages/AdminUsersPage.jsx +++ b/frontend/src/pages/AdminUsersPage.jsx @@ -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 ( - + Gestion des comptes @@ -164,7 +162,7 @@ const AdminUsersPage = () => { return ( - + Gestion des comptes @@ -173,13 +171,13 @@ const AdminUsersPage = () => { {error} - )} + )}
- Nom d'utilisateur + Nom Couleur Rôle Inscrit le @@ -189,7 +187,7 @@ const AdminUsersPage = () => { {users.map((user) => ( - {user.username} + {user.first_name} {user.last_name}
@@ -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 = () => { - + Modifier l'utilisateur
-
- - setEditForm({ ...editForm, username: event.target.value })} - /> -
diff --git a/frontend/src/pages/HomePage.jsx b/frontend/src/pages/HomePage.jsx index 8b21b20..9bb4979 100644 --- a/frontend/src/pages/HomePage.jsx +++ b/frontend/src/pages/HomePage.jsx @@ -10,7 +10,7 @@ const HomePage = () => { return (
- + Profil Utilisateur diff --git a/frontend/src/pages/LoginPage.jsx b/frontend/src/pages/LoginPage.jsx index 77a1996..775cd90 100644 --- a/frontend/src/pages/LoginPage.jsx +++ b/frontend/src/pages/LoginPage.jsx @@ -32,7 +32,7 @@ const LoginPage = () => { return (
- + Connexion diff --git a/frontend/src/pages/RegisterPage.jsx b/frontend/src/pages/RegisterPage.jsx index 57b215b..fb2483b 100644 --- a/frontend/src/pages/RegisterPage.jsx +++ b/frontend/src/pages/RegisterPage.jsx @@ -47,7 +47,7 @@ const RegisterPage = () => { return (
- + Lien invalide @@ -62,7 +62,7 @@ const RegisterPage = () => { return (
- + Compte créé @@ -79,7 +79,7 @@ const RegisterPage = () => { return (
- + Créer un compte diff --git a/frontend/src/pages/TimeClockPage.jsx b/frontend/src/pages/TimeClockPage.jsx index 30a87aa..cf34330 100644 --- a/frontend/src/pages/TimeClockPage.jsx +++ b/frontend/src/pages/TimeClockPage.jsx @@ -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 ( + + + } + > + + + {selectedDate ? formatDateLabel(value) : placeholder} + + + + + { + if (!date) return; + + onChange(formatDateValue(date)); + setOpen(false); + }} + /> + + + ); +}; + +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 ( + + + } + > + + + {value || placeholder} + + + + +
+ Choisir une heure + + {value || '--:--'} + +
+ +
+
+

+ Heure +

+
+ {hourOptions.map((hour) => ( + + ))} +
+
+ +
+

+ Minutes +

+
+ {minuteOptions.map((minute) => ( + + ))} +
+
+
+ +
+ +
+
+
+ ); +}; 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 ( - + Pointage @@ -466,54 +662,16 @@ const TimeClockPage = () => { )} - {/* Dialog for QR scanner preview */} - { - if (open) { - setIsScannerDialogOpen(true); - } else { - closeScanner(); - } - }} - > - - - - - Aperçu de la caméra - - -
-
+ {cameraPermissionError && ( +
+ +

+ Si le navigateur a déjà bloqué la caméra, autorisez-la dans les paramètres du site puis cliquez ici pour réessayer. +

+
+ )} {scanMessage && ( @@ -523,18 +681,15 @@ const TimeClockPage = () => { {scanConflict && ( - + Conflit de pointage

{scanConflict.message}

- setConflictTime(event.target.value)} - className="mb-2" + onChange={setConflictTime} />
- +
) : (
-
+
- setNewEntry({...newEntry, date: e.target.value})} - required + onChange={(value) => + setNewEntry({ ...newEntry, date: value }) + } />
- setNewEntry({...newEntry, end_date: e.target.value})} - required + onChange={(value) => + setNewEntry({ ...newEntry, end_date: value }) + } />
- setNewEntry({...newEntry, start_time: e.target.value})} - required + onChange={(value) => + setNewEntry({ ...newEntry, start_time: value }) + } />
- setNewEntry({...newEntry, end_time: e.target.value})} - required + onChange={(value) => + setNewEntry({ ...newEntry, end_time: value }) + } />
@@ -645,73 +794,102 @@ const TimeClockPage = () => { )} - {/* Time entries list */} - - - Historique des pointages - - - {loading ? ( -

Chargement des pointages...

- ) : timeEntries.length === 0 ? ( -

Aucun pointage enregistré

- ) : ( -
-
- - - {isAdmin && Utilisateur} - Date - Entrée - Sortie - Durée - {isAdmin && Actions} - - - - {getDisplayTimeEntries().map((entry) => ( - - {isAdmin && ( - {entry.user} - )} - {formatDate(entry.date)} - {formatTime(entry.start_time)} - {formatTime(entry.end_time)} - - - {formatDuration(entry.duration)} - - - {isAdmin && ( - -
- - handleDeleteEntry(entry.id)} - > - - -
-
- )} +
+ {/* Pointage uniquement disponible sur mobile */} +
+ { + if (open) { + setIsScannerDialogOpen(true); + } else { + closeScanner(); + } + }} + > + + + + + Aperçu de la caméra + +
+
+
+ + {/* Sur ordinateur, le pointage est volontairement indisponible */} + {!isAdmin && +
+

+ Le pointage est indisponible sur ordinateur. +

+
+ } + + + + Historique des pointages + + + {loading ? ( +

Chargement des pointages...

+ ) : timeEntries.length === 0 ? ( +

Aucun pointage enregistré

+ ) : ( +
+
+ + + {isAdmin && Nom} + Date + Entrée + Sortie + Durée + {isAdmin && Actions} - ))} - -
-
- )} -
-
+ + + {getDisplayTimeEntries().map((entry) => ( + + {isAdmin && {entry.first_name} {entry.last_name}} + {formatDate(entry.date)} + {formatTime(entry.start_time)} + {formatTime(entry.end_time)} + {formatDuration(entry.duration)} + {isAdmin && ( + +
+ + handleDeleteEntry(entry.id)}> + + +
+
+ )} +
+ ))} +
+ + + )} + + + diff --git a/frontend/src/pages/WeeklySummaryPage.jsx b/frontend/src/pages/WeeklySummaryPage.jsx index 6bca675..e83c645 100644 --- a/frontend/src/pages/WeeklySummaryPage.jsx +++ b/frontend/src/pages/WeeklySummaryPage.jsx @@ -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 }) => { {legendUsers.map( - ({ username, color }) => ( + ({ first_name, color }) => (
{ /> - {username} + {first_name}
), @@ -715,7 +715,7 @@ const WeeklySummaryPage = ({ userId }) => { ({ start, end, - username, + first_name, color, }, columnIndex) => { const style = @@ -731,7 +731,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 }) => {
{ color, }} > - {username} + {first_name}
@@ -972,9 +972,9 @@ const WeeklySummaryPage = ({ userId }) => { users .map( ({ - username, + first_name, }) => - username, + first_name, ) .join(', ');