Louis Mazin 162744ff4d test
2026-09-21 13:21:34 +02:00

3430 lines
77 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (
<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>
);
};
/* =========================================================
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 (
<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
? 'tabular-nums'
: 'text-muted-foreground'
}
>
{value || placeholder}
</span>
</PopoverTrigger>
<PopoverContent
className="w-[190px] p-3"
align="start"
>
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-medium">
Choisir une heure
</span>
<span className="font-mono text-sm font-semibold tabular-nums">
{value || '--:--'}
</span>
</div>
<div className="relative flex h-[144px] gap-2 overflow-hidden">
<div className="pointer-events-none absolute inset-x-0 top-1/2 z-10 h-9 -translate-y-1/2 rounded-md border bg-muted/50" />
{/* HEURES */}
<div
className="flex-1 touch-none select-none"
onWheel={(event) =>
handleWheel(
event,
'hours',
)
}
onTouchStart={handleTouchStart}
onTouchMove={(event) =>
handleTouchMove(
event,
'hours',
)
}
onTouchEnd={handleTouchEnd}
onTouchCancel={handleTouchEnd}
>
<div className="flex h-full flex-col items-center justify-center">
<span className="h-9 text-sm text-muted-foreground/40">
{
hourOptions[
(Number(hours || 0) +
23) %
24
]
}
</span>
<span className="flex h-9 w-full items-center justify-center rounded-md font-mono text-sm font-semibold tabular-nums">
{hours || '00'}
</span>
<span className="h-9 text-sm text-muted-foreground/40">
{
hourOptions[
(Number(hours || 0) +
1) %
24
]
}
</span>
</div>
</div>
{/* : */}
<div className="flex items-center justify-center">
<span className="font-bold">
:
</span>
</div>
{/* MINUTES */}
<div
className="flex-1 touch-none select-none"
onWheel={(event) =>
handleWheel(
event,
'minutes',
)
}
onTouchStart={handleTouchStart}
onTouchMove={(event) =>
handleTouchMove(
event,
'minutes',
)
}
onTouchEnd={handleTouchEnd}
onTouchCancel={handleTouchEnd}
>
<div className="flex h-full flex-col items-center justify-center">
<span className="h-9 text-sm text-muted-foreground/40">
{
minuteOptions[
(Number(minutes || 0) +
59) %
60
]
}
</span>
<span className="flex h-9 w-full items-center justify-center rounded-md font-mono text-sm font-semibold tabular-nums">
{minutes || '00'}
</span>
<span className="h-9 text-sm text-muted-foreground/40">
{
minuteOptions[
(Number(minutes || 0) +
1) %
60
]
}
</span>
</div>
</div>
</div>
<p className="mt-2 text-center text-[10px] text-muted-foreground">
Glissez pour modifier
</p>
<div className="mt-2 border-t pt-2">
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 w-full text-xs"
disabled={!value}
onClick={clearTime}
>
Effacer
</Button>
</div>
</PopoverContent>
</Popover>
);
};
/* =========================================================
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 (
<span className="whitespace-nowrap">
{startDate}
<span className="mx-1 text-muted-foreground">
→
</span>
{endDate}
</span>
);
};
/* =========================================================
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 (
<Card className="page-container page-layout flex h-full min-h-0 w-full flex-col overflow-hidden">
<CardHeader className="shrink-0 border-b">
<CardTitle>
Pointage
</CardTitle>
</CardHeader>
{/* IMPORTANT :
flex + flex-col + min-h-0 permettent à la grille
de récupérer la hauteur restante sur desktop.
Mobile garde le scroll global. */}
<CardContent className="flex min-h-0 flex-1 flex-col overflow-y-auto lg:overflow-hidden">
{/* =========================
ALERTES
========================== */}
{error && (
<Alert
variant="destructive"
className="mb-4 shrink-0"
>
<AlertDescription>
{error}
</AlertDescription>
</Alert>
)}
{cameraPermissionError && (
<div className="mb-4 flex shrink-0 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 shrink-0"
>
<AlertDescription>
{scanMessage}
</AlertDescription>
</Alert>
)}
{/* =====================================================
DIALOG CONFLIT DE POINTAGE
====================================================== */}
<Dialog
open={Boolean(scanConflict)}
onOpenChange={(open) => {
if (!open) {
cancelScanConflict();
}
}}
>
<DialogContent className="w-[calc(100%-2rem)] max-w-md">
<DialogHeader>
<DialogTitle>
{scanConflict?.step ===
'confirm'
? 'Confirmation du pointage'
: 'Heure manquante'}
</DialogTitle>
</DialogHeader>
{scanConflictConfig && (
<div className="space-y-4">
{scanConflict.step ===
'confirm' ? (
<>
<p className="text-sm text-muted-foreground">
{
scanConflictConfig.confirmationMessage
}
</p>
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
<Button
type="button"
variant="secondary"
onClick={
cancelScanConflict
}
>
Non, annuler
</Button>
<Button
type="button"
onClick={
startScanConflictResolution
}
>
Oui, continuer
</Button>
</div>
</>
) : (
<>
<p className="text-sm text-muted-foreground">
{
scanConflictConfig.confirmationMessage
}
</p>
<div>
<Label>
{
scanConflictConfig.timeLabel
}
</Label>
<div className="mt-2">
<TimePicker
value={
conflictTime
}
onChange={
setConflictTime
}
/>
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
<Button
type="button"
variant="secondary"
onClick={
cancelScanConflict
}
>
Annuler
</Button>
<Button
type="button"
onClick={
resolveScanConflict
}
>
Enregistrer
</Button>
</div>
</>
)}
</div>
)}
</DialogContent>
</Dialog>
{/* =====================================================
BOUTON POINTER — MOBILE UNIQUEMENT
====================================================== */}
<Dialog
open={
isScannerDialogOpen
}
onOpenChange={(
open,
) => {
if (open) {
setIsScannerDialogOpen(
true,
);
} else {
closeScanner();
}
}}
>
<Button
onClick={
startScanner
}
variant="default"
className="mb-6 w-full shrink-0 lg:hidden"
>
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>
{/* =====================================================
GESTION + HISTORIQUE
====================================================== */}
<div
className={`grid w-full min-h-0 grid-cols-1 gap-6 lg:flex-1 ${
isAdmin ? 'lg:grid-cols-2' : ''
}`}
>
{/* =================================================
COLONNE GAUCHE
================================================== */}
<div className="flex min-w-0 flex-col lg:h-full lg:min-h-0">
{isAdmin && (
<Card className="lg:h-full">
<CardHeader className="border-b">
<CardTitle>
Gestion des pointages
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* UTILISATEUR */}
<div>
<Label htmlFor="selected-user">
Voir les pointages de :
</Label>
<select
id="selected-user"
value={
selectedUserId
}
disabled={
showAllUsers
}
onChange={(
event,
) => {
setSelectedUserId(
event.target
.value,
);
setEditingEntry(
null,
);
setError('');
}}
className="mt-2 flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
>
{users.length ===
0 ? (
<option value="">
Aucun utilisateur
disponible
</option>
) : (
users.map(
(user) => (
<option
key={getUserId(
user,
)}
value={getUserId(
user,
)}
>
{getUserLabel(
user,
)}
</option>
),
)
)}
</select>
<label className="mt-3 flex h-10 cursor-pointer items-center gap-2 rounded-md border px-3 text-sm">
<input
type="checkbox"
checked={
showAllUsers
}
onChange={(
event,
) => {
setShowAllUsers(
event.target
.checked,
);
setEditingEntry(
null,
);
setError('');
}}
className="size-4 accent-primary"
/>
<span>
Voir tout le monde
</span>
</label>
</div>
{/* SEPARATION */}
{!showAllUsers && (
<div className="border-t pt-6">
<div className="mb-4">
<h3 className="text-sm font-medium">
{editingEntry
? 'Modifier le pointage'
: `Ajouter un pointage pour${
selectedUser
? ` ${getUserLabel(
selectedUser,
)}`
: ''
}`}
</h3>
</div>
{/* =============================
EDITION
============================== */}
{editingEntry ? (
<form
onSubmit={
handleUpdateEntry
}
>
<div className="mb-4 grid grid-cols-1 gap-4">
{/* DATE D'ENTRÉE */}
<div>
<Label>
Date d'entrée :
</Label>
{editingEntry.isGrouped ? (
<div className="mt-2 flex h-10 items-center rounded-md border bg-muted/50 px-3 text-sm">
{formatDateLabel(
editForm.date,
)}
</div>
) : (
<DatePicker
value={
editForm.date
}
onChange={(
value,
) =>
setEditForm(
{
...editForm,
date:
value,
end_date:
editForm.end_date &&
editForm.end_date <
value
? value
: editForm.end_date,
},
)
}
/>
)}
</div>
{/* DATE DE SORTIE */}
<div>
<Label>
Date de sortie :
</Label>
{editingEntry.isGrouped ? (
<div className="mt-2 flex h-10 items-center rounded-md border bg-muted/50 px-3 text-sm">
{formatDateLabel(
editForm.end_date,
)}
</div>
) : (
<DatePicker
value={
editForm.end_date
}
min={
editForm.date
}
onChange={(
value,
) =>
setEditForm(
{
...editForm,
end_date:
value,
},
)
}
/>
)}
</div>
{/* HEURE D'ENTRÉE */}
<div>
<Label>
Heure d'entrée :
</Label>
<TimePicker
value={
editForm.start_time
}
onChange={(
value,
) =>
setEditForm(
{
...editForm,
start_time:
value,
},
)
}
/>
</div>
{/* HEURE DE SORTIE */}
<div>
<Label>
Heure de sortie :
</Label>
<TimePicker
value={
editForm.end_time
}
onChange={(
value,
) =>
setEditForm(
{
...editForm,
end_time:
value,
},
)
}
/>
</div>
</div>
{editingEntry.isGrouped && (
<p className="mb-4 text-xs text-muted-foreground">
Les dates d’un pointage
multi-jours sont liées au
groupe. Seules les heures
peuvent être modifiées ici.
</p>
)}
<div className="flex flex-col gap-2 sm:flex-row">
<Button type="submit">
Enregistrer
</Button>
<Button
type="button"
variant="secondary"
onClick={() =>
setEditingEntry(
null,
)
}
>
Annuler
</Button>
</div>
</form>
) : (
/* =============================
AJOUT
============================== */
<form
onSubmit={
handleAddEntry
}
>
<div className="mb-4 grid grid-cols-1 gap-4">
{/* DATE D'ENTRÉE */}
<div>
<Label>
Date d'entrée :
</Label>
<DatePicker
value={
newEntry.date
}
onChange={(
value,
) =>
setNewEntry(
{
...newEntry,
date:
value,
end_date:
newEntry.end_date &&
newEntry.end_date <
value
? value
: newEntry.end_date,
},
)
}
/>
</div>
{/* DATE DE SORTIE */}
<div>
<Label>
Date de sortie :
</Label>
<DatePicker
value={
newEntry.end_date
}
min={
newEntry.date
}
onChange={(
value,
) =>
setNewEntry(
{
...newEntry,
end_date:
value,
},
)
}
/>
</div>
{/* HEURE D'ENTRÉE */}
<div>
<Label>
Heure d'entrée :
</Label>
<TimePicker
value={
newEntry.start_time
}
onChange={(
value,
) =>
setNewEntry(
{
...newEntry,
start_time:
value,
},
)
}
/>
</div>
{/* HEURE DE SORTIE */}
<div>
<Label>
Heure de sortie :
</Label>
<TimePicker
value={
newEntry.end_time
}
onChange={(
value,
) =>
setNewEntry(
{
...newEntry,
end_time:
value,
},
)
}
/>
</div>
</div>
<Button
type="submit"
className="w-full"
>
Ajouter le pointage
</Button>
</form>
)}
</div>
)}
</CardContent>
</Card>
)}
</div>
{/* =================================================
HISTORIQUE
================================================== */}
<div className="flex min-w-0 flex-col lg:h-full lg:min-h-0">
<Card className="flex h-auto min-h-0 flex-col overflow-hidden lg:h-full lg:flex-1">
<CardHeader className="shrink-0 border-b">
<CardTitle>
Historique des pointages{' '}
{!showAllUsers &&
`de ${getUserLabel(
selectedUser,
)}`}
</CardTitle>
</CardHeader>
{/* DESKTOP : SEUL CETTE ZONE SCROLLE
MOBILE : LE SCROLL EST GÉRÉ PAR LE CARD
PRINCIPAL */}
<CardContent className="min-h-0 flex-1 overflow-visible lg:overflow-y-auto">
{loading ? (
<p>
Chargement des
pointages...
</p>
) : displayedTimeEntries.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>
<TableHead>
Statut
</TableHead>
{isAdmin && (
<TableHead>
Actions
</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{displayedTimeEntries.map(
(entry) => {
const duration =
entry.isGrouped
? entry.displayDuration
: entry.displayDuration ??
entry.duration;
return (
<TableRow
key={
entry.id
}
>
{isAdmin && (
<TableCell>
{getEntryUserLabel(
entry,
)}
</TableCell>
)}
{/* DATE */}
<TableCell>
{formatEntryDate(
entry,
)}
</TableCell>
{/* ENTRÉE */}
<TableCell>
{formatTime(
entry.start_time,
)}
</TableCell>
{/* SORTIE */}
<TableCell>
{formatTime(
entry.end_time,
)}
</TableCell>
{/* DURÉE */}
<TableCell className="text-center">
{duration !==
null &&
duration !==
undefined ? (
<Badge variant="secondary">
{formatDuration(
duration,
)}
</Badge>
) : (
<span className="text-sm text-muted-foreground">
────
</span>
)}
</TableCell>
{/* STATUT */}
<TableCell>
{entry.start_time &&
entry.end_time ? (
<Badge variant="secondary">
Complet
</Badge>
) : entry.start_time ? (
<Badge variant="default">
Entrée ouverte
</Badge>
) : (
<Badge variant="destructive">
Sortie sans entrée
</Badge>
)}
</TableCell>
{/* ACTIONS */}
{isAdmin && (
<TableCell>
<div className="flex gap-2">
<Button
onClick={() =>
handleEditEntry(
entry,
)
}
variant="secondary"
size="sm"
>
Modifier
</Button>
<ConfirmDialog
title="Supprimer le pointage"
description={
entry.isGrouped
? `Êtes-vous sûr de vouloir supprimer ce pointage multi-jours du ${formatDate(
entry.date,
)} au ${formatDate(
entry.end_date,
)} ?`
: 'Êtes-vous sûr de vouloir supprimer le pointage ?'
}
onConfirm={() =>
handleDeleteEntry(
entry,
)
}
>
<Button
variant="destructive"
size="sm"
>
Supprimer
</Button>
</ConfirmDialog>
</div>
</TableCell>
)}
</TableRow>
);
},
)}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</div>
</div>
</CardContent>
</Card>
);
};
export default TimeClockPage;