3430 lines
77 KiB
JavaScript
3430 lines
77 KiB
JavaScript
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; |