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 { Label } from '@/components/ui/label'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; 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 { Calendar } from '@/components/ui/calendar'; import { Popover, PopoverContent, PopoverTrigger, } from '@/components/ui/popover'; import { CalendarDays, Clock, } from 'lucide-react'; /* ========================================================= DATE HELPERS ========================================================= */ const getTodayValue = () => { const date = new Date(); 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 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 formatDate = ( dateString, ) => { if (!dateString) { return '────'; } const date = parseDateValue(dateString); if (!date) { return '────'; } return date.toLocaleDateString( 'fr-FR', ); }; /* ========================================================= DATE/TIME DURATION HELPERS ========================================================= */ const parseDateTime = ( dateValue, timeValue, ) => { if (!dateValue || !timeValue) { return null; } const [ year, month, day, ] = dateValue.split('-').map(Number); const [ hours, minutes, seconds = 0, ] = timeValue .split(':') .map(Number); if ( !year || !month || !day || Number.isNaN(hours) || Number.isNaN(minutes) ) { return null; } return new Date( year, month - 1, day, hours, minutes, Number(seconds) || 0, ); }; const calculateDuration = ({ startDate, startTime, endDate, endTime, }) => { if ( !startDate || !startTime || !endDate || !endTime ) { return null; } const start = parseDateTime( startDate, startTime, ); const end = parseDateTime( endDate, endTime, ); if (!start || !end) { return null; } const difference = end.getTime() - start.getTime(); if (difference < 0) { return null; } return ( difference / (1000 * 60 * 60) ); }; /* ========================================================= DATE PICKER ========================================================= */ 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); }} /> ); }; /* ========================================================= TIME PICKER ========================================================= */ const TimePicker = ({ value, onChange, placeholder = 'Choisir une heure', }) => { const [open, setOpen] = useState(false); const touchState = useRef({ active: false, lastY: 0, accumulated: 0, }); const [hours = '', minutes = ''] = value ? value.split(':') : []; const hourOptions = Array.from( { length: 24 }, (_, i) => String(i).padStart(2, '0'), ); const minuteOptions = Array.from( { length: 60 }, (_, i) => String(i).padStart(2, '0'), ); const updateTime = ( nextHours, nextMinutes, ) => { onChange( `${String(nextHours).padStart(2, '0')}:${String( nextMinutes, ).padStart(2, '0')}`, ); }; const changeHour = (direction) => { const currentHour = Number(hours || 0); const currentMinute = Number(minutes || 0); let nextHour = currentHour + direction; if (nextHour < 0) { nextHour = 23; } if (nextHour > 23) { nextHour = 0; } updateTime( nextHour, currentMinute, ); }; const changeMinute = (direction) => { const currentHour = Number(hours || 0); const currentMinute = Number(minutes || 0); let nextMinute = currentMinute + direction; let nextHour = currentHour; if (nextMinute < 0) { nextMinute = 59; nextHour = currentHour === 0 ? 23 : currentHour - 1; } if (nextMinute > 59) { nextMinute = 0; nextHour = currentHour === 23 ? 0 : currentHour + 1; } updateTime( nextHour, nextMinute, ); }; // PC : une molette = une unité const handleWheel = ( event, type, ) => { event.preventDefault(); event.stopPropagation(); const direction = event.deltaY > 0 ? 1 : -1; if (type === 'hours') { changeHour(direction); } else { changeMinute(direction); } }; // MOBILE : début du déplacement const handleTouchStart = (event) => { const touch = event.touches[0]; touchState.current = { active: true, lastY: touch.clientY, accumulated: 0, }; }; // MOBILE : déplacement continu const handleTouchMove = ( event, type, ) => { if (!touchState.current.active) { return; } const touch = event.touches[0]; const currentY = touch.clientY; const deltaY = touchState.current.lastY - currentY; touchState.current.lastY = currentY; touchState.current.accumulated += deltaY; const pixelsPerUnit = 18; const steps = Math.trunc( touchState.current.accumulated / pixelsPerUnit, ); if (steps === 0) { return; } touchState.current.accumulated -= steps * pixelsPerUnit; if (type === 'hours') { changeHour(steps); } else { changeMinute(steps); } event.preventDefault(); event.stopPropagation(); }; const handleTouchEnd = () => { touchState.current.active = false; touchState.current.accumulated = 0; }; const clearTime = () => { onChange(''); setOpen(false); }; return ( } > {value || placeholder}
Choisir une heure {value || '--:--'}
{/* HEURES */}
handleWheel( event, 'hours', ) } onTouchStart={handleTouchStart} onTouchMove={(event) => handleTouchMove( event, 'hours', ) } onTouchEnd={handleTouchEnd} onTouchCancel={handleTouchEnd} >
{ hourOptions[ (Number(hours || 0) + 23) % 24 ] } {hours || '00'} { hourOptions[ (Number(hours || 0) + 1) % 24 ] }
{/* : */}
:
{/* MINUTES */}
handleWheel( event, 'minutes', ) } onTouchStart={handleTouchStart} onTouchMove={(event) => handleTouchMove( event, 'minutes', ) } onTouchEnd={handleTouchEnd} onTouchCancel={handleTouchEnd} >
{ minuteOptions[ (Number(minutes || 0) + 59) % 60 ] } {minutes || '00'} { minuteOptions[ (Number(minutes || 0) + 1) % 60 ] }

Glissez pour modifier

); }; /* ========================================================= 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 today = getTodayValue(); const [ newEntry, setNewEntry, ] = useState({ date: today, end_date: today, start_time: '', end_time: '', }); const [ editingEntry, setEditingEntry, ] = useState(null); const [ editForm, setEditForm, ] = useState({ date: '', end_date: '', start_time: '', end_time: '', }); /* ========================================================= USER HELPERS ========================================================= */ const getUserId = (user) => { if (!user) return ''; if (typeof user === 'number') { return String(user); } if (typeof user === 'string') { return /^\d+$/.test(user) ? user : ''; } return String( user.id ?? 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' ) { 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(); return ( fullName || user.username || user.name || user.user_name || user.email || `Utilisateur #${getUserId(user)}` ); }; const getEntryUserId = ( entry, ) => { if (!entry) return ''; const directId = entry.user_id ?? entry.userId ?? entry.employee_id ?? entry.employeeId ?? entry.user_pk; if ( directId !== undefined && directId !== null && directId !== '' ) { return String(directId); } if ( entry.user && typeof entry.user === 'object' ) { return getUserId( entry.user, ); } if ( typeof entry.user === 'number' ) { return String(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, ); } return ( entry.username || entry.user_name || entry.userName || ( typeof entry.user === 'string' && !/^\d+$/.test(entry.user) ? entry.user : '' ) || `Utilisateur #${getEntryUserId( entry, )}` ); }; const getCurrentUserId = () => { return ( getUserId(authUser) || getUserId(currentUser) ); }; /* ========================================================= GROUP MULTI-DAY ENTRIES ========================================================= */ const getEntrySortTime = ( entry, ) => { if (!entry?.start_time) { return ''; } return entry.start_time; }; const buildDisplayTimeEntries = ( entries, ) => { if (!Array.isArray(entries)) { return []; } const groupedEntries = new Map(); const normalEntries = []; entries.forEach( (entry) => { if (!entry?.group_id) { normalEntries.push({ ...entry, isGrouped: false, sourceEntries: [entry], displayStartDate: entry.date, displayEndDate: entry.date, displayStartTime: entry.start_time, displayEndTime: entry.end_time, displayDuration: calculateDuration({ startDate: entry.date, startTime: entry.start_time, endDate: entry.date, endTime: entry.end_time, }), }); return; } const groupKey = String(entry.group_id); if ( !groupedEntries.has( groupKey, ) ) { groupedEntries.set( groupKey, [], ); } groupedEntries .get(groupKey) .push(entry); }, ); const groupedDisplayEntries = Array.from( groupedEntries.values(), ).map( (groupEntries) => { const sortedEntries = [...groupEntries].sort( (a, b) => { const dateCompare = String( a.date || '', ).localeCompare( String( b.date || '', ), ); if ( dateCompare !== 0 ) { return dateCompare; } return getEntrySortTime( a, ).localeCompare( getEntrySortTime( b, ), ); }, ); const firstEntry = sortedEntries[0]; const lastEntry = sortedEntries[ sortedEntries.length - 1 ]; const startDate = firstEntry?.date || ''; const endDate = lastEntry?.date || startDate; const startTime = firstEntry?.start_time || null; const endTime = lastEntry?.end_time || null; return { ...firstEntry, id: `group-${String( firstEntry.group_id, )}`, group_id: firstEntry.group_id, isGrouped: true, sourceEntries: sortedEntries, date: startDate, end_date: endDate, start_time: startTime, end_time: endTime, displayStartDate: startDate, displayEndDate: endDate, displayStartTime: startTime, displayEndTime: endTime, displayDuration: calculateDuration({ startDate, startTime, endDate, endTime, }), }; }, ); const result = [ ...normalEntries, ...groupedDisplayEntries, ]; return result.sort( (a, b) => { const dateCompare = String( b.displayStartDate || b.date || '', ).localeCompare( String( a.displayStartDate || a.date || '', ), ); if ( dateCompare !== 0 ) { return dateCompare; } return String( b.displayStartTime || b.start_time || '', ).localeCompare( String( a.displayStartTime || a.start_time || '', ), ); }, ); }; /* ========================================================= LOAD ENTRIES ========================================================= */ const loadTimeEntries = async () => { try { setLoading(true); const entries = await timeApi.getTimeEntries(); setTimeEntries(entries); if (!isAdmin) return; let availableUsers = []; if ( typeof timeApi.getUsers === 'function' ) { 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, ); } } 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, ); }, ); 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(); }, []); /* ========================================================= VALIDATION ========================================================= */ const validateEntry = ({ date, end_date, start_time, end_time, }) => { if (!date || !end_date) { return 'Veuillez renseigner les dates.'; } if ( !start_time || !end_time ) { return 'Veuillez renseigner une heure d’entrée et une heure de sortie.'; } if (end_date < date) { return 'La date de sortie doit être postérieure ou égale à la date d’entrée.'; } if ( date === end_date && end_time <= start_time ) { return "L'heure de sortie doit être après l'heure d'entrée lorsque le pointage est sur la même journée."; } return ''; }; /* ========================================================= ADD ENTRY ========================================================= */ const handleAddEntry = async (e) => { e.preventDefault(); const validationError = validateEntry(newEntry); if (validationError) { setError( validationError, ); return; } if ( isAdmin && !showAllUsers && !selectedUserId ) { setError( 'Veuillez sélectionner un utilisateur.', ); return; } try { await timeApi.createTimeEntry( { date: newEntry.date, end_date: newEntry.end_date, start_time: newEntry.start_time, end_time: newEntry.end_time, ...(isAdmin && selectedUserId ? { user_id: selectedUserId, } : {}), }, ); const today = getTodayValue(); setNewEntry({ date: today, end_date: today, start_time: '', end_time: '', }); await loadTimeEntries(); setError(''); } catch (err) { setError( err.message || "Erreur lors de l'ajout du pointage", ); console.error(err); } }; /* ========================================================= EDIT ENTRY ========================================================= */ const handleEditEntry = ( entry, ) => { setEditingEntry(entry); if (entry.isGrouped) { const sortedEntries = [...( entry.sourceEntries || [] )].sort( (a, b) => { const dateCompare = String( a.date || '', ).localeCompare( String( b.date || '', ), ); if ( dateCompare !== 0 ) { return dateCompare; } return String( a.start_time || '', ).localeCompare( String( b.start_time || '', ), ); }, ); const firstEntry = sortedEntries[0]; const lastEntry = sortedEntries[ sortedEntries.length - 1 ]; setEditForm({ date: firstEntry?.date || entry.date || '', end_date: lastEntry?.date || entry.end_date || entry.date || '', start_time: firstEntry?.start_time || '', end_time: lastEntry?.end_time || '', }); } else { setEditForm({ date: entry.date || '', end_date: entry.end_date || entry.date || '', start_time: entry.start_time || '', end_time: entry.end_time || '', }); } setError(''); }; const handleUpdateEntry = async (e) => { e.preventDefault(); if (!editingEntry) { return; } const validationError = validateEntry(editForm); if (validationError) { setError( validationError, ); return; } try { if ( editingEntry.isGrouped ) { const sortedEntries = [...( editingEntry.sourceEntries || [] )].sort( (a, b) => { const dateCompare = String( a.date || '', ).localeCompare( String( b.date || '', ), ); if ( dateCompare !== 0 ) { return dateCompare; } return String( a.start_time || '', ).localeCompare( String( b.start_time || '', ), ); }, ); const firstEntry = sortedEntries[0]; const lastEntry = sortedEntries[ sortedEntries.length - 1 ]; if ( !firstEntry || !lastEntry ) { throw new Error( 'Impossible de déterminer les lignes du pointage multi-jours.', ); } if ( firstEntry.id === lastEntry.id ) { await timeApi.updateTimeEntry( firstEntry.id, { start_time: editForm.start_time, end_time: editForm.end_time, }, ); } else { await timeApi.updateTimeEntry( firstEntry.id, { start_time: editForm.start_time, end_time: firstEntry.end_time, }, ); await timeApi.updateTimeEntry( lastEntry.id, { start_time: lastEntry.start_time, end_time: editForm.end_time, }, ); } } else { await timeApi.updateTimeEntry( editingEntry.id, { start_time: editForm.start_time, end_time: editForm.end_time, }, ); } setEditingEntry(null); setEditForm({ date: '', end_date: '', start_time: '', end_time: '', }); await loadTimeEntries(); setError(''); } catch (err) { setError( err.message || 'Erreur lors de la mise à jour du pointage', ); console.error(err); } }; /* ========================================================= DELETE ENTRY ========================================================= */ const handleDeleteEntry = async (entry) => { try { if ( entry?.isGrouped && Array.isArray( entry.sourceEntries, ) ) { for ( const sourceEntry of entry.sourceEntries ) { await timeApi.deleteTimeEntry( sourceEntry.id, ); } } else { await timeApi.deleteTimeEntry( entry.id, ); } await loadTimeEntries(); setError(''); } catch (err) { setError( 'Erreur lors de la suppression du pointage', ); console.error(err); } }; /* ========================================================= SCANNER ========================================================= */ const stopScanner = () => { scannerSessionRef.current += 1; if ( qrControlsRef.current ) { try { qrControlsRef.current.stop(); } catch (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; } }; const closeScanner = () => { stopScanner(); setIsScannerDialogOpen( false, ); }; const handleQrResult = async ( code, sessionId, ) => { if ( sessionId !== scannerSessionRef.current ) { return; } setError(''); setScanMessage(''); setScanConflict(null); closeScanner(); try { const response = await timeApi.scanTimeEntry( code, ); setScanMessage( response.message, ); await loadTimeEntries(); } catch ( scanError ) { if ( scanError.code === 409 && scanError.conflict ) { const nowTime = new Date() .toTimeString() .slice(0, 5); setConflictTime( scanError.scanTime || nowTime, ); setScanConflict({ code, type: scanError.conflict, message: scanError.message, resolution: scanError.resolution, missingTime: scanError.missingTime, scanTime: scanError.scanTime, step: 'confirm', }); return; } setError( scanError.message, ); } }; const getScanConflictConfig = () => { if (!scanConflict) { return null; } if ( scanConflict.type === 'open_entry' ) { return { confirmationTitle: 'Une entrée est déjà ouverte', confirmationMessage: 'Voulez-vous enregistrer une sortie pour cette entrée ?', timeLabel: 'Heure de sortie', resolution: 'complete_exit', }; } if ( scanConflict.type === 'missing_entry' ) { return { confirmationTitle: 'Aucune entrée ouverte', confirmationMessage: 'Voulez-vous enregistrer cette sortie malgré l’absence d’entrée ?', timeLabel: "Heure d'entrée", resolution: 'complete_missing_entry', }; } if ( scanConflict.type === 'open_exit' ) { return { confirmationTitle: 'Une sortie sans entrée existe', confirmationMessage: "Voulez-vous renseigner l’heure d’entrée ?", timeLabel: "Heure d'entrée", resolution: 'complete_open_exit', }; } return null; }; const startScanConflictResolution = () => { setScanConflict( (current) => current ? { ...current, step: 'time', } : null, ); }; const cancelScanConflict = () => { setScanConflict(null); setError(''); }; const resolveScanConflict = async () => { if (!scanConflict) { return; } const config = getScanConflictConfig(); if (!config) { return; } setError(''); try { const options = { resolution: config.resolution, time: conflictTime, }; if ( scanConflict.scanTime ) { options.scan_time = scanConflict.scanTime; } const response = await timeApi.scanTimeEntry( scanConflict.code, options, ); setScanMessage( response.message, ); setScanConflict(null); await loadTimeEntries(); } catch ( resolutionError ) { setError( resolutionError.message, ); } }; useEffect(() => { return () => { stopScanner(); }; }, []); 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, ); }, ); if ( cancelled || sessionId !== scannerSessionRef.current || !isScannerDialogOpen ) { try { controls.stop(); } catch ( stopError ) { console.warn( 'Arrêt du scanner ignoré.', stopError, ); } return; } qrControlsRef.current = controls; } catch ( scannerError ) { 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.", ); } else { setCameraPermissionError( false, ); setError( "Impossible d’accéder à la caméra. Vérifiez les permissions et utilisez HTTPS sur le réseau.", ); } closeScanner(); } }; startReader(); return () => { cancelled = true; try { reader?.reset(); } catch ( resetError ) { console.warn( 'Réinitialisation du lecteur QR ignorée.', resetError, ); } if ( sessionId === scannerSessionRef.current ) { stopScanner(); } }; }, [ isScannerDialogOpen, ]); const startScanner = () => { setError(''); setScanMessage(''); setScanConflict(null); setCameraPermissionError( false, ); 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; } 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.", ); } } }; /* ========================================================= DISPLAY HELPERS ========================================================= */ const formatTime = ( timeString, ) => { if (!timeString) { return '────'; } const [ hours, minutes, ] = timeString.split(':'); return `${String( hours, ).padStart( 2, '0', )}h${String( minutes, ).padStart( 2, '0', )}`; }; const formatDuration = ( duration, ) => { if ( duration === null || duration === undefined ) { return '────'; } const totalMinutes = Math.round( Number(duration) * 60, ); const hours = String( Math.floor( totalMinutes / 60, ), ).padStart(2, '0'); const minutes = String( totalMinutes % 60, ).padStart(2, '0'); return `${hours}h${minutes}`; }; const formatEntryDate = ( entry, ) => { if (!entry?.date) { return '────'; } const startDate = formatDate(entry.date); const endDate = entry.end_date && entry.end_date !== entry.date ? formatDate( entry.end_date, ) : null; if (!endDate) { return startDate; } return ( {startDate} → {endDate} ); }; /* ========================================================= FILTER + GROUP ========================================================= */ const filteredTimeEntries = isAdmin && !showAllUsers && selectedUserId ? timeEntries.filter( (entry) => getEntryUserId( entry, ) === String( selectedUserId, ), ) : timeEntries; const displayedTimeEntries = buildDisplayTimeEntries( filteredTimeEntries, ); const selectedUser = users.find( (user) => getUserId(user) === String( selectedUserId, ), ); /* ========================================================= RENDER ========================================================= */ const scanConflictConfig = getScanConflictConfig(); return ( Pointage {/* IMPORTANT : flex + flex-col + min-h-0 permettent à la grille de récupérer la hauteur restante sur desktop. Mobile garde le scroll global. */} {/* ========================= ALERTES ========================== */} {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.

)} {scanMessage && ( {scanMessage} )} {/* ===================================================== DIALOG CONFLIT DE POINTAGE ====================================================== */} { if (!open) { cancelScanConflict(); } }} > {scanConflict?.step === 'confirm' ? 'Confirmation du pointage' : 'Heure manquante'} {scanConflictConfig && (
{scanConflict.step === 'confirm' ? ( <>

{ scanConflictConfig.confirmationMessage }

) : ( <>

{ scanConflictConfig.confirmationMessage }

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

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

{/* ============================= EDITION ============================== */} {editingEntry ? (
{/* DATE D'ENTRÉE */}
{editingEntry.isGrouped ? (
{formatDateLabel( editForm.date, )}
) : ( setEditForm( { ...editForm, date: value, end_date: editForm.end_date && editForm.end_date < value ? value : editForm.end_date, }, ) } /> )}
{/* DATE DE SORTIE */}
{editingEntry.isGrouped ? (
{formatDateLabel( editForm.end_date, )}
) : ( setEditForm( { ...editForm, end_date: value, }, ) } /> )}
{/* HEURE D'ENTRÉE */}
setEditForm( { ...editForm, start_time: value, }, ) } />
{/* HEURE DE SORTIE */}
setEditForm( { ...editForm, end_time: value, }, ) } />
{editingEntry.isGrouped && (

Les dates d’un pointage multi-jours sont liées au groupe. Seules les heures peuvent être modifiées ici.

)}
) : ( /* ============================= AJOUT ============================== */
{/* DATE D'ENTRÉE */}
setNewEntry( { ...newEntry, date: value, end_date: newEntry.end_date && newEntry.end_date < value ? value : newEntry.end_date, }, ) } />
{/* DATE DE SORTIE */}
setNewEntry( { ...newEntry, end_date: value, }, ) } />
{/* HEURE D'ENTRÉE */}
setNewEntry( { ...newEntry, start_time: value, }, ) } />
{/* HEURE DE SORTIE */}
setNewEntry( { ...newEntry, end_time: value, }, ) } />
)}
)}
)}
{/* ================================================= HISTORIQUE ================================================== */}
Historique des pointages{' '} {!showAllUsers && `de ${getUserLabel( selectedUser, )}`} {/* DESKTOP : SEUL CETTE ZONE SCROLLE MOBILE : LE SCROLL EST GÉRÉ PAR LE CARD PRINCIPAL */} {loading ? (

Chargement des pointages...

) : displayedTimeEntries.length === 0 ? (

Aucun pointage enregistré

) : (
{isAdmin && ( Nom )} Date Entrée Sortie Durée Statut {isAdmin && ( Actions )} {displayedTimeEntries.map( (entry) => { const duration = entry.isGrouped ? entry.displayDuration : entry.displayDuration ?? entry.duration; return ( {isAdmin && ( {getEntryUserLabel( entry, )} )} {/* DATE */} {formatEntryDate( entry, )} {/* ENTRÉE */} {formatTime( entry.start_time, )} {/* SORTIE */} {formatTime( entry.end_time, )} {/* DURÉE */} {duration !== null && duration !== undefined ? ( {formatDuration( duration, )} ) : ( ──── )} {/* STATUT */} {entry.start_time && entry.end_time ? ( Complet ) : entry.start_time ? ( Entrée ouverte ) : ( Sortie sans entrée )} {/* ACTIONS */} {isAdmin && (
handleDeleteEntry( entry, ) } >
)}
); }, )}
)}
); }; export default TimeClockPage;