From 4da5ee31ec695786966ccbd82b76a4c2b316a349 Mon Sep 17 00:00:00 2001 From: Louis Mazin Date: Sun, 20 Sep 2026 01:28:14 +0200 Subject: [PATCH] nice --- frontend/src/pages/TimeClockPage.jsx | 2567 +++++++++++++++++++------- 1 file changed, 1875 insertions(+), 692 deletions(-) diff --git a/frontend/src/pages/TimeClockPage.jsx b/frontend/src/pages/TimeClockPage.jsx index ebe5aa6..392a6f4 100644 --- a/frontend/src/pages/TimeClockPage.jsx +++ b/frontend/src/pages/TimeClockPage.jsx @@ -2,23 +2,59 @@ import { useEffect, useRef, useState } from 'react'; import { BrowserQRCodeReader } from '@zxing/browser'; import { timeApi } from '../api/time'; import { useAuth } from '../context/useAuth'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Input } from '@/components/ui/input'; + +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from '@/components/ui/card'; + import { Label } from '@/components/ui/label'; import { Button } from '@/components/ui/button'; -import { Alert, AlertDescription } from '@/components/ui/alert'; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; + +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 } 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'; +import { + CalendarDays, + Clock, +} from 'lucide-react'; + + +/* ========================================================= + DATE PICKER +========================================================= */ const parseDateValue = (value) => { if (!value) return undefined; @@ -30,6 +66,7 @@ const parseDateValue = (value) => { return new Date(year, month - 1, day); }; + const formatDateValue = (date) => { if (!date) return ''; @@ -40,6 +77,7 @@ const formatDateValue = (date) => { return `${year}-${month}-${day}`; }; + const formatDateLabel = (value) => { const date = parseDateValue(value); @@ -52,6 +90,7 @@ const formatDateLabel = (value) => { }); }; + const DatePicker = ({ value, onChange, @@ -75,17 +114,37 @@ const DatePicker = ({ } > - - {selectedDate ? formatDateLabel(value) : placeholder} + + + {selectedDate + ? formatDateLabel(value) + : placeholder} - + { if (!date) return; @@ -98,6 +157,11 @@ const DatePicker = ({ ); }; + +/* ========================================================= + TIME PICKER +========================================================= */ + const TimePicker = ({ value, onChange, @@ -105,25 +169,45 @@ const TimePicker = ({ }) => { const [open, setOpen] = useState(false); - const [hours = '', minutes = ''] = value ? value.split(':') : []; + const [hours = '', minutes = ''] = + value ? value.split(':') : []; - const setTimePart = (part, nextValue) => { - const nextHours = part === 'hours' ? nextValue : hours || '00'; - const nextMinutes = part === 'minutes' ? nextValue : minutes || '00'; + const setTimePart = ( + part, + nextValue, + ) => { + const nextHours = + part === 'hours' + ? nextValue + : hours || '00'; - onChange(`${nextHours}:${nextMinutes}`); + const nextMinutes = + part === 'minutes' + ? nextValue + : minutes || '00'; + + onChange( + `${nextHours}:${nextMinutes}`, + ); }; - const hourOptions = Array.from({ length: 24 }, (_, index) => - String(index).padStart(2, '0') + const hourOptions = Array.from( + { length: 24 }, + (_, index) => + String(index).padStart(2, '0'), ); - const minuteOptions = Array.from({ length: 60 }, (_, index) => - String(index).padStart(2, '0') + const minuteOptions = Array.from( + { length: 60 }, + (_, index) => + String(index).padStart(2, '0'), ); return ( - + - + + {value || placeholder} - +
- Choisir une heure + + Choisir une heure + + {value || '--:--'}
+ + {/* HEURES */}

Heure

+
{hourOptions.map((hour) => ( @@ -168,25 +277,39 @@ const TimePicker = ({
+ {/* MINUTES */}

Minutes

+
- {minuteOptions.map((minute) => ( - - ))} + {minuteOptions.map( + (minute) => ( + + ), + )}
+
@@ -208,66 +331,168 @@ const TimePicker = ({ ); }; + +/* ========================================================= + PAGE +========================================================= */ + const TimeClockPage = () => { const videoRef = useRef(null); const qrControlsRef = useRef(null); - const { isAdmin, user: authUser, currentUser } = useAuth(); - const [scanMessage, setScanMessage] = useState(''); - const [scanConflict, setScanConflict] = useState(null); - const [conflictTime, setConflictTime] = useState(new Date().toTimeString().slice(0, 5)); - const [error, setError] = useState(''); - const [timeEntries, setTimeEntries] = useState([]); - const [users, setUsers] = useState([]); - const [selectedUserId, setSelectedUserId] = useState(''); - const [showAllUsers, setShowAllUsers] = useState(false); - 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], + + const { + isAdmin, + user: authUser, + currentUser, + } = useAuth(); + + const [ + scanMessage, + setScanMessage, + ] = useState(''); + + const [ + scanConflict, + setScanConflict, + ] = useState(null); + + const [ + conflictTime, + setConflictTime, + ] = useState( + new Date() + .toTimeString() + .slice(0, 5), + ); + + const [ + error, + setError, + ] = useState(''); + + const [ + timeEntries, + setTimeEntries, + ] = useState([]); + + const [ + users, + setUsers, + ] = useState([]); + + const [ + selectedUserId, + setSelectedUserId, + ] = useState(''); + + const [ + showAllUsers, + setShowAllUsers, + ] = useState(false); + + 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], + start_time: '', - end_time: '' + end_time: '', }); - const [editingEntry, setEditingEntry] = useState(null); - const [editForm, setEditForm] = useState({ + + const [ + editingEntry, + setEditingEntry, + ] = useState(null); + + const [ + editForm, + setEditForm, + ] = useState({ start_time: '', - end_time: '' + end_time: '', }); - + + + /* ========================================================= + USER HELPERS + ========================================================= */ const getUserId = (user) => { if (!user) return ''; - if (typeof user === 'number') return String(user); + if (typeof user === 'number') { + return String(user); + } + if (typeof user === 'string') { - return /^\d+$/.test(user) ? user : ''; + return /^\d+$/.test(user) + ? user + : ''; } return String( user.id ?? - user.pk ?? - user.user_id ?? - user.userId ?? - user.user?.id ?? - user.user?.pk ?? - '' + user.pk ?? + user.user_id ?? + user.userId ?? + user.user?.id ?? + user.user?.pk ?? + '', ); }; - const getUserLabel = (user) => { - if (!user) return 'Utilisateur'; - if (typeof user === 'string' || typeof user === 'number') { + const getUserLabel = (user) => { + if (!user) { + return 'Utilisateur'; + } + + if ( + typeof user === 'string' || + typeof user === 'number' + ) { return /^\d+$/.test(String(user)) ? `Utilisateur #${user}` : String(user); } - const firstName = user.first_name || user.firstName || ''; - const lastName = user.last_name || user.lastName || ''; - const fullName = `${firstName} ${lastName}`.trim(); + const firstName = + user.first_name || + user.firstName || + ''; + + const lastName = + user.last_name || + user.lastName || + ''; + + const fullName = + `${firstName} ${lastName}`.trim(); return ( fullName || @@ -279,6 +504,7 @@ const TimeClockPage = () => { ); }; + const getEntryUserId = (entry) => { if (!entry) return ''; @@ -289,320 +515,627 @@ const TimeClockPage = () => { entry.employeeId ?? entry.user_pk; - if (directId !== undefined && directId !== null && directId !== '') { + if ( + directId !== undefined && + directId !== null && + directId !== '' + ) { return String(directId); } - if (entry.user && typeof entry.user === 'object') { + if ( + entry.user && + typeof entry.user === 'object' + ) { return getUserId(entry.user); } - // Certaines réponses Django renvoient simplement `user: 2` - // au lieu de `user: { id: 2, ... }`. - if (typeof entry.user === 'number') { + if ( + typeof entry.user === 'number' + ) { return String(entry.user); } - if (typeof entry.user === 'string' && /^\d+$/.test(entry.user)) { + if ( + typeof entry.user === 'string' && + /^\d+$/.test(entry.user) + ) { return entry.user; } return ''; }; - const getEntryUserLabel = (entry) => { - if (!entry) return 'Utilisateur inconnu'; - if (entry.user && typeof entry.user === 'object') { - return getUserLabel(entry.user); + const getEntryUserLabel = (entry) => { + if (!entry) { + return 'Utilisateur inconnu'; + } + + if ( + entry.user && + typeof entry.user === 'object' + ) { + return getUserLabel( + entry.user, + ); } return ( entry.username || entry.user_name || entry.userName || - (typeof entry.user === 'string' && !/^\d+$/.test(entry.user) - ? entry.user - : '') || + ( + typeof entry.user === 'string' && + !/^\d+$/.test(entry.user) + ? entry.user + : '' + ) || `Utilisateur #${getEntryUserId(entry)}` ); }; + const getCurrentUserId = () => { - return getUserId(authUser) || getUserId(currentUser); + return ( + getUserId(authUser) || + getUserId(currentUser) + ); }; - const loadTimeEntries = async () => { - try { - setLoading(true); - const entries = await timeApi.getTimeEntries(); - setTimeEntries(entries); + /* ========================================================= + LOAD ENTRIES + ========================================================= */ - if (!isAdmin) return; + const loadTimeEntries = + async () => { + try { + setLoading(true); - // Si l'API expose une liste d'utilisateurs, on l'utilise. - // Sinon, on reconstruit une liste à partir des pointages retournés. - let availableUsers = []; + const entries = + await timeApi.getTimeEntries(); - if (typeof timeApi.getUsers === 'function') { - try { - const apiUsers = await timeApi.getUsers(); + setTimeEntries(entries); - if (Array.isArray(apiUsers)) { - availableUsers = apiUsers; - } else if (Array.isArray(apiUsers?.results)) { - availableUsers = apiUsers.results; - } else if (Array.isArray(apiUsers?.data)) { - availableUsers = apiUsers.data; - } else if (Array.isArray(apiUsers?.users)) { - availableUsers = apiUsers.users; - } - } catch (usersError) { - console.warn('Impossible de charger les utilisateurs.', usersError); - } - } + if (!isAdmin) return; - // Sécurité : on ajoute les utilisateurs trouvés dans les pointages - // qui ne seraient pas présents dans /users/. - const usersById = new Map( - availableUsers - .map((user) => [getUserId(user), user]) - .filter(([id]) => id), - ); + let availableUsers = []; - entries.forEach((entry) => { - const id = getEntryUserId(entry); - - if (!id || usersById.has(id)) return; - - const entryUser = - typeof entry.user === 'object' && entry.user - ? entry.user - : { - id, - username: entry.username, - name: entry.user_name || entry.userName, - first_name: entry.first_name, - last_name: entry.last_name, - email: entry.email, - }; - - usersById.set(id, entryUser); - }); - - availableUsers = [...usersById.values()] - .filter((user) => getUserId(user)) - .sort((a, b) => - getUserLabel(a).localeCompare(getUserLabel(b), 'fr'), - ); - - setUsers(availableUsers); - - const currentUserId = getCurrentUserId(); - - setSelectedUserId((currentSelectedId) => { if ( - currentSelectedId && - availableUsers.some( - (user) => getUserId(user) === String(currentSelectedId), - ) + typeof timeApi.getUsers === + 'function' ) { - return currentSelectedId; + try { + const apiUsers = + await timeApi.getUsers(); + + if ( + Array.isArray(apiUsers) + ) { + availableUsers = + apiUsers; + } else if ( + Array.isArray( + apiUsers?.results, + ) + ) { + availableUsers = + apiUsers.results; + } else if ( + Array.isArray( + apiUsers?.data, + ) + ) { + availableUsers = + apiUsers.data; + } else if ( + Array.isArray( + apiUsers?.users, + ) + ) { + availableUsers = + apiUsers.users; + } + } catch ( + usersError + ) { + console.warn( + 'Impossible de charger les utilisateurs.', + usersError, + ); + } } - return ( - currentUserId || - getUserId(availableUsers[0]) || - '' + const usersById = + new Map( + availableUsers + .map((user) => [ + getUserId(user), + user, + ]) + .filter( + ([id]) => id, + ), + ); + + entries.forEach( + (entry) => { + const id = + getEntryUserId( + entry, + ); + + if ( + !id || + usersById.has(id) + ) { + return; + } + + const entryUser = + typeof entry.user === + 'object' && + entry.user + ? entry.user + : { + id, + username: + entry.username, + name: + entry.user_name || + entry.userName, + first_name: + entry.first_name, + last_name: + entry.last_name, + email: + entry.email, + }; + + usersById.set( + id, + entryUser, + ); + }, ); - }); - } catch (err) { - setError('Erreur lors du chargement des pointages'); - console.error(err); - } finally { - setLoading(false); - } - }; + + availableUsers = + [...usersById.values()] + .filter( + (user) => + getUserId(user), + ) + .sort((a, b) => + getUserLabel(a).localeCompare( + getUserLabel(b), + 'fr', + ), + ); + + setUsers( + availableUsers, + ); + + const currentUserId = + getCurrentUserId(); + + setSelectedUserId( + (currentSelectedId) => { + if ( + currentSelectedId && + availableUsers.some( + (user) => + getUserId(user) === + String( + currentSelectedId, + ), + ) + ) { + return currentSelectedId; + } + + return ( + currentUserId || + getUserId( + availableUsers[0], + ) || + '' + ); + }, + ); + } catch (err) { + setError( + 'Erreur lors du chargement des pointages', + ); + + console.error(err); + } finally { + setLoading(false); + } + }; + + useEffect(() => { loadTimeEntries(); }, []); - const handleAddEntry = async (e) => { - e.preventDefault(); - if (!newEntry.start_time || !newEntry.end_time) { - setError('Veuillez remplir tous les champs'); - return; - } - if (isAdmin && !showAllUsers && !selectedUserId) { - setError('Veuillez sélectionner un utilisateur.'); - return; - } + /* ========================================================= + ADD ENTRY + ========================================================= */ - try { - if (newEntry.date === newEntry.end_date) { - await timeApi.createTimeEntry({ - date: newEntry.date, - start_time: newEntry.start_time, - end_time: newEntry.end_time, - ...(isAdmin && selectedUserId - ? { user_id: selectedUserId } - : {}), - }); - } else { - const dates = []; - const cursor = new Date(`${newEntry.date}T12:00:00`); - const lastDate = new Date(`${newEntry.end_date}T12:00:00`); + const handleAddEntry = + async (e) => { + e.preventDefault(); - while (cursor <= lastDate) { - dates.push(cursor.toISOString().slice(0, 10)); - cursor.setDate(cursor.getDate() + 1); - } + if ( + !newEntry.start_time || + !newEntry.end_time + ) { + setError( + 'Veuillez remplir tous les champs', + ); + return; + } - await Promise.all(dates.map((date, index) => { - if (index === 0) { - return timeApi.createTimeEntry({ - date, - start_time: newEntry.start_time, - ...(isAdmin && selectedUserId - ? { user_id: selectedUserId } - : {}), - }); - } - if (index === dates.length - 1) { - return timeApi.createTimeEntry({ - date, - end_time: newEntry.end_time, - ...(isAdmin && selectedUserId - ? { user_id: selectedUserId } - : {}), - }); - } - return timeApi.createTimeEntry({ - date, - start_time: '00:00', - end_time: '23:59', - ...(isAdmin && selectedUserId - ? { user_id: selectedUserId } + if ( + isAdmin && + !showAllUsers && + !selectedUserId + ) { + setError( + 'Veuillez sélectionner un utilisateur.', + ); + return; + } + + try { + if ( + newEntry.date === + newEntry.end_date + ) { + await timeApi.createTimeEntry({ + date: newEntry.date, + start_time: + newEntry.start_time, + end_time: + newEntry.end_time, + + ...(isAdmin && + selectedUserId + ? { + user_id: + selectedUserId, + } : {}), }); - })); + } else { + const dates = []; + + const cursor = new Date( + `${newEntry.date}T12:00:00`, + ); + + const lastDate = new Date( + `${newEntry.end_date}T12:00:00`, + ); + + while ( + cursor <= lastDate + ) { + dates.push( + cursor + .toISOString() + .slice(0, 10), + ); + + cursor.setDate( + cursor.getDate() + 1, + ); + } + + await Promise.all( + dates.map( + (date, index) => { + if (index === 0) { + return timeApi.createTimeEntry( + { + date, + start_time: + newEntry.start_time, + + ...(isAdmin && + selectedUserId + ? { + user_id: + selectedUserId, + } + : {}), + }, + ); + } + + if ( + index === + dates.length - 1 + ) { + return timeApi.createTimeEntry( + { + date, + end_time: + newEntry.end_time, + + ...(isAdmin && + selectedUserId + ? { + user_id: + selectedUserId, + } + : {}), + }, + ); + } + + return timeApi.createTimeEntry( + { + date, + start_time: + '00:00', + end_time: + '23:59', + + ...(isAdmin && + selectedUserId + ? { + user_id: + selectedUserId, + } + : {}), + }, + ); + }, + ), + ); + } + + setNewEntry({ + date: new Date() + .toISOString() + .split('T')[0], + + end_date: new Date() + .toISOString() + .split('T')[0], + + start_time: '', + end_time: '', + }); + + loadTimeEntries(); + setError(''); + } catch (err) { + setError( + "Erreur lors de l'ajout du pointage", + ); + + console.error(err); } - setNewEntry({ - date: new Date().toISOString().split('T')[0], - end_date: new Date().toISOString().split('T')[0], - start_time: '', - end_time: '' + }; + + + /* ========================================================= + EDIT ENTRY + ========================================================= */ + + const handleEditEntry = + (entry) => { + setEditingEntry(entry); + + setEditForm({ + start_time: + entry.start_time, + end_time: + entry.end_time, }); - loadTimeEntries(); // Refresh the list - setError(''); - } catch (err) { - setError('Erreur lors de l\'ajout du pointage'); - console.error(err); - } - }; + }; - const handleEditEntry = (entry) => { - setEditingEntry(entry); - setEditForm({ - start_time: entry.start_time, - end_time: entry.end_time - }); - }; - const handleUpdateEntry = async (e) => { - e.preventDefault(); - if (!editForm.start_time && !editForm.end_time) { - setError('Veuillez remplir tous les champs'); - return; - } + const handleUpdateEntry = + async (e) => { + e.preventDefault(); - try { - await timeApi.updateTimeEntry(editingEntry.id, editForm); - setEditingEntry(null); - setEditForm({ start_time: '', end_time: '' }); - loadTimeEntries(); // Refresh the list - setError(''); - } catch (err) { - setError('Erreur lors de la mise à jour du pointage'); - console.error(err); - } - }; + if ( + !editForm.start_time && + !editForm.end_time + ) { + setError( + 'Veuillez remplir tous les champs', + ); - const handleDeleteEntry = async (id) => { - try { - await timeApi.deleteTimeEntry(id); - loadTimeEntries(); // Refresh the list - setError(''); - } catch (err) { - setError('Erreur lors de la suppression du pointage'); - console.error(err); - } - }; + return; + } + + try { + await timeApi.updateTimeEntry( + editingEntry.id, + editForm, + ); + + setEditingEntry(null); + + setEditForm({ + start_time: '', + end_time: '', + }); + + loadTimeEntries(); + setError(''); + } catch (err) { + setError( + 'Erreur lors de la mise à jour du pointage', + ); + + console.error(err); + } + }; + + + /* ========================================================= + DELETE ENTRY + ========================================================= */ + + const handleDeleteEntry = + async (id) => { + try { + await timeApi.deleteTimeEntry( + id, + ); + + loadTimeEntries(); + setError(''); + } catch (err) { + setError( + 'Erreur lors de la suppression du pointage', + ); + + console.error(err); + } + }; + + + /* ========================================================= + SCANNER + ========================================================= */ const stopScanner = () => { scannerSessionRef.current += 1; - if (qrControlsRef.current) { + if ( + qrControlsRef.current + ) { try { qrControlsRef.current.stop(); } catch (stopError) { - console.warn('Arrêt du lecteur QR déjà effectué.', stopError); + console.warn( + 'Arrêt du lecteur QR déjà effectué.', + stopError, + ); } + qrControlsRef.current = null; } - if (videoRef.current?.srcObject) { - const stream = videoRef.current.srcObject; - stream.getTracks().forEach((track) => track.stop()); - videoRef.current.srcObject = null; + if ( + videoRef.current?.srcObject + ) { + const stream = + videoRef.current + .srcObject; + + stream + .getTracks() + .forEach( + (track) => + track.stop(), + ); + + videoRef.current.srcObject = + null; } }; + const closeScanner = () => { stopScanner(); setIsScannerDialogOpen(false); }; - const handleQrResult = async (code, sessionId) => { - if (sessionId !== scannerSessionRef.current) return; - setScanConflict(null); - closeScanner(); - - try { - const response = await timeApi.scanTimeEntry(code); - setScanMessage(response.message); - await loadTimeEntries(); - } catch (scanError) { - if (scanError.code === 409 && scanError.conflict) { - setScanConflict({ - code, - type: scanError.conflict, - message: scanError.message, - }); - } else { - setError(scanError.message); + const handleQrResult = + async ( + code, + sessionId, + ) => { + if ( + sessionId !== + scannerSessionRef.current + ) { + return; } - } - }; - 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(); - } catch (resolutionError) { - setError(resolutionError.message); - } - }; + + closeScanner(); + + try { + const response = + await timeApi.scanTimeEntry( + code, + ); + + setScanMessage( + response.message, + ); + + await loadTimeEntries(); + } catch ( + scanError + ) { + if ( + scanError.code === 409 && + scanError.conflict + ) { + setScanConflict({ + code, + type: + scanError.conflict, + message: + scanError.message, + }); + } else { + setError( + scanError.message, + ); + } + } + }; + + + 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(); + } catch ( + resolutionError + ) { + setError( + resolutionError.message, + ); + } + }; + useEffect(() => { return () => { @@ -610,95 +1143,143 @@ const TimeClockPage = () => { }; }, []); - useEffect(() => { - if (!isScannerDialogOpen) return undefined; - const sessionId = scannerSessionRef.current; + useEffect(() => { + if ( + !isScannerDialogOpen + ) { + return undefined; + } + + const sessionId = + scannerSessionRef.current; + let cancelled = false; let reader = null; - const startReader = async () => { - await new Promise((resolve) => { - requestAnimationFrame(() => { - requestAnimationFrame(resolve); - }); - }); - - if ( - cancelled || - sessionId !== scannerSessionRef.current || - !videoRef.current - ) { - return; - } - - reader = new BrowserQRCodeReader(); - - try { - const controls = await reader.decodeFromConstraints( - { - video: { - facingMode: { ideal: 'environment' }, - }, - audio: false, - }, - videoRef.current, - (result) => { - if ( - !result || - cancelled || - sessionId !== scannerSessionRef.current - ) { - return; - } - - handleQrResult(result.getText(), sessionId); + const startReader = + async () => { + await new Promise( + (resolve) => { + requestAnimationFrame( + () => { + requestAnimationFrame( + resolve, + ); + }, + ); }, ); if ( cancelled || - sessionId !== scannerSessionRef.current || - !isScannerDialogOpen + sessionId !== + scannerSessionRef.current || + !videoRef.current ) { - try { - controls.stop(); - } catch (stopError) { - console.warn('Arrêt du scanner ignoré.', stopError); + return; + } + + reader = + new BrowserQRCodeReader(); + + try { + const controls = + await reader.decodeFromConstraints( + { + video: { + facingMode: { + ideal: + 'environment', + }, + }, + audio: false, + }, + videoRef.current, + (result) => { + if ( + !result || + cancelled || + sessionId !== + scannerSessionRef.current + ) { + return; + } + + handleQrResult( + result.getText(), + sessionId, + ); + }, + ); + + if ( + cancelled || + sessionId !== + scannerSessionRef.current || + !isScannerDialogOpen + ) { + try { + controls.stop(); + } catch ( + stopError + ) { + console.warn( + 'Arrêt du scanner ignoré.', + stopError, + ); + } + + return; } - return; - } - qrControlsRef.current = controls; - } catch (scannerError) { - if ( - cancelled || - sessionId !== scannerSessionRef.current + qrControlsRef.current = + controls; + } catch ( + scannerError ) { - return; - } + if ( + cancelled || + sessionId !== + scannerSessionRef.current + ) { + return; + } - console.error('Erreur scanner QR:', scannerError); - - 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.', + console.error( + 'Erreur scanner QR:', + scannerError, ); - } else { - setCameraPermissionError(false); - setError( - 'Impossible d’accéder à la caméra. Vérifiez les permissions et utilisez HTTPS sur le réseau.', - ); - } - closeScanner(); - } - }; + 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(); + } + }; startReader(); @@ -707,18 +1288,26 @@ const TimeClockPage = () => { try { reader?.reset(); - } catch (resetError) { + } catch ( + resetError + ) { console.warn( 'Réinitialisation du lecteur QR ignorée.', resetError, ); } - if (sessionId === scannerSessionRef.current) { + if ( + sessionId === + scannerSessionRef.current + ) { stopScanner(); } }; - }, [isScannerDialogOpen]); + }, [ + isScannerDialogOpen, + ]); + const startScanner = () => { setError(''); @@ -728,436 +1317,1030 @@ const TimeClockPage = () => { if ( !window.isSecureContext && - window.location.hostname !== 'localhost' && - window.location.hostname !== '192.168.1.19' + 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.'); + if ( + !navigator.mediaDevices + ?.getUserMedia + ) { + setError( + 'Ce navigateur ne permet pas l’accès à la caméra.', + ); + return; } 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, - ); + const requestCameraPermission = + async () => { + setError(''); if ( - permissionError?.name === 'NotAllowedError' || - permissionError?.name === 'PermissionDeniedError' + !window.isSecureContext && + window.location.hostname !== + 'localhost' && + window.location.hostname !== + '192.168.1.19' ) { - 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.', + 'La caméra nécessite une connexion HTTPS sur cette adresse réseau.', ); + + return; } - } - }; - const formatTime = (timeString) => { - if (!timeString) return '00h00'; - const [hours, minutes] = timeString.split(':'); - if (hours === '24') return '23h59'; - return `${String(hours).padStart(2, '0')}h${String(minutes).padStart(2, '0')}`; - }; + if ( + !navigator.mediaDevices + ?.getUserMedia + ) { + setError( + 'Ce navigateur ne permet pas l’accès à la caméra.', + ); - const getDisplayTimeEntries = () => { - const toMinutes = (time) => { - const [hours, minutes] = time.split(':').map(Number); - return hours * 60 + minutes; + 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.", + ); + } + } }; - return timeEntries.map((entry) => { - if (entry.start_time && !entry.end_time) { - return { - ...entry, - end_time: '24:00:00', - duration: (1440 - toMinutes(entry.start_time)) / 60, - }; - } - if (!entry.start_time && entry.end_time) { - return { - ...entry, - start_time: '00:00:00', - duration: toMinutes(entry.end_time) / 60, - }; - } - return entry; - }); + + /* ========================================================= + DISPLAY HELPERS + ========================================================= */ + + const formatTime = ( + timeString, + ) => { + if (!timeString) { + return '00h00'; + } + + const [ + hours, + minutes, + ] = timeString.split(':'); + + if (hours === '24') { + return '23h59'; + } + + return `${String(hours).padStart( + 2, + '0', + )}h${String(minutes).padStart( + 2, + '0', + )}`; }; - const formatDate = (dateString) => { - const date = new Date(dateString); - return date.toLocaleDateString('fr-FR'); + + const getDisplayTimeEntries = + () => { + const toMinutes = + (time) => { + const [ + hours, + minutes, + ] = time + .split(':') + .map(Number); + + return ( + hours * 60 + + minutes + ); + }; + + return timeEntries.map( + (entry) => { + if ( + entry.start_time && + !entry.end_time + ) { + return { + ...entry, + end_time: + '24:00:00', + + duration: + (1440 - + toMinutes( + entry.start_time, + )) / + 60, + }; + } + + if ( + !entry.start_time && + entry.end_time + ) { + return { + ...entry, + start_time: + '00:00:00', + + duration: + toMinutes( + entry.end_time, + ) / 60, + }; + } + + return entry; + }, + ); + }; + + + const formatDate = ( + dateString, + ) => { + const date = + new Date(dateString); + + return date.toLocaleDateString( + 'fr-FR', + ); }; - const formatDuration = (duration) => { - const totalMinutes = Math.round(Number(duration || 0) * 60); - const hours = String(Math.floor(totalMinutes / 60)).padStart(2, '0'); - const minutes = String(totalMinutes % 60).padStart(2, '0'); + + const formatDuration = ( + duration, + ) => { + const totalMinutes = + Math.round( + Number( + duration || 0, + ) * 60, + ); + + const hours = String( + Math.floor( + totalMinutes / 60, + ), + ).padStart(2, '0'); + + const minutes = String( + totalMinutes % 60, + ).padStart(2, '0'); return `${hours}h${minutes}`; }; - const displayedTimeEntries = isAdmin && !showAllUsers && selectedUserId - ? timeEntries.filter( - (entry) => getEntryUserId(entry) === String(selectedUserId), - ) - : timeEntries; - const selectedUser = users.find( - (user) => getUserId(user) === String(selectedUserId), - ); + const displayedTimeEntries = + isAdmin && + !showAllUsers && + selectedUserId + ? timeEntries.filter( + (entry) => + getEntryUserId( + entry, + ) === + String( + selectedUserId, + ), + ) + : timeEntries; + + + const selectedUser = + users.find( + (user) => + getUserId(user) === + String( + selectedUserId, + ), + ); + + + /* ========================================================= + RENDER + ========================================================= */ return ( - - - Pointage + + + + + Pointage + - + + + + {/* ========================= + ALERTES + ========================== */} + {error && ( - - {error} + + + {error} + )} + {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. + 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 && ( - - {scanMessage} + + + {scanMessage} + )} + + {/* ========================= + CONFLIT + ========================== */} + {scanConflict && ( - + - Conflit de pointage + + Conflit de pointage + + -

{scanConflict.message}

- +

+ {scanConflict.message} +

+ + + -
- - +
)} - {/* Time tracking section */} -
- {isAdmin && ( - - -
-
+ + {/* ===================================================== + POINTAGE + HISTORIQUE + ====================================================== */} + +
+ + {/* ================================================= + COLONNE GAUCHE + POINTAGE + ================================================== */} + +
+ + {/* ========================= + BOUTON POINTER + ========================== */} + { + if (open) { + setIsScannerDialogOpen( + true, + ); + } else { + closeScanner(); + } + }} + > + + + + + + + + + Aperçu de la caméra + + + +
+ +
+ + + {/* ========================= + GESTION ADMIN + ========================== */} + + {isAdmin && ( + + + + + Gestion des pointages + + + + + + {/* UTILISATEUR */} + +
+ + + + + +
- -
- - - )} - {isAdmin && !showAllUsers && ( - + {/* SEPARATION */} + + {!showAllUsers && ( +
+ +
+

+ {editingEntry + ? 'Modifier le pointage' + : `Ajouter un pointage pour${ + selectedUser + ? ` ${getUserLabel( + selectedUser, + )}` + : '' + }`} +

+
+ + + {/* EDITION */} + + {editingEntry ? ( +
+ +
+ +
+ + + + setEditForm( + { + ...editForm, + start_time: + value, + }, + ) + } + /> +
+ + +
+ + + + setEditForm( + { + ...editForm, + end_time: + value, + }, + ) + } + /> +
+ +
+ + +
+ + + + + +
+ +
+ ) : ( + + /* AJOUT */ + +
+ +
+ +
+ + + + setNewEntry( + { + ...newEntry, + date: + value, + }, + ) + } + /> +
+ + +
+ + + + setNewEntry( + { + ...newEntry, + end_date: + value, + }, + ) + } + /> +
+ + +
+ + + + setNewEntry( + { + ...newEntry, + start_time: + value, + }, + ) + } + /> +
+ + +
+ + + + setNewEntry( + { + ...newEntry, + end_time: + value, + }, + ) + } + /> +
+ +
+ + + + +
+ )} + +
+ )} + + + +
+ )} + +
+ + + {/* ================================================= + COLONNE DROITE + HISTORIQUE + ================================================== */} + +
+ + + - {editingEntry - ? 'Modifier le pointage' - : `Ajouter un pointage pour${selectedUser ? ` ${getUserLabel(selectedUser)}` : ''}`} + Historique des pointages{' '} + {!showAllUsers && + `de ${getUserLabel( + selectedUser, + )}`} + + - {editingEntry ? ( -
-
-
- - - setEditForm({ ...editForm, start_time: value }) - } - /> -
-
- - - setEditForm({ ...editForm, end_time: value }) - } - /> -
-
-
- - -
-
- ) : ( -
-
-
- - - setNewEntry({ ...newEntry, date: value }) - } - /> -
-
- - - setNewEntry({ ...newEntry, end_date: value }) - } - /> -
-
- - - setNewEntry({ ...newEntry, start_time: value }) - } - /> -
-
- - - setNewEntry({ ...newEntry, end_time: value }) - } - /> -
-
- -
- )} -
-
- )} -
- {/* 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 { - !showAllUsers && `de ${getUserLabel(selectedUser)}` - } - - {loading ? ( -

Chargement des pointages...

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

Aucun pointage enregistré

+

+ Chargement des + pointages... +

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

+ Aucun pointage + enregistré +

) : ( +
+ + + - {isAdmin && Nom} - Date - Entrée - Sortie - Durée - {isAdmin && Actions} + + {isAdmin && ( + + Nom + + )} + + + Date + + + + Entrée + + + + Sortie + + + + Durée + + + {isAdmin && ( + + Actions + + )} + + + + + {getDisplayTimeEntries() .filter( (entry) => !isAdmin || showAllUsers || - getEntryUserId(entry) === String(selectedUserId), + getEntryUserId( + entry, + ) === + String( + selectedUserId, + ), ) - .map((entry) => ( - - {isAdmin && {getEntryUserLabel(entry)}} - {formatDate(entry.date)} - {formatTime(entry.start_time)} - {formatTime(entry.end_time)} - {formatDuration(entry.duration)} - {isAdmin && ( - -
- - handleDeleteEntry(entry.id)}> - - -
-
- )} -
- ))} + .map( + (entry) => ( + + + {isAdmin && ( + + {getEntryUserLabel( + entry, + )} + + )} + + + {formatDate( + entry.date, + )} + + + + {formatTime( + entry.start_time, + )} + + + + {formatTime( + entry.end_time, + )} + + + + + {formatDuration( + entry.duration, + )} + + + + + {isAdmin && ( + + +
+ + + + + + handleDeleteEntry( + entry.id, + ) + } + > + + + + + +
+ +
+ )} + +
+ ), + )} +
+
+
)} +
+
+
+
+ ); }; + export default TimeClockPage; \ No newline at end of file