nice
This commit is contained in:
parent
bb0f242dd5
commit
ba76763c24
@ -26,7 +26,6 @@ const AdminUsersPage = () => {
|
|||||||
const [isCopied, setIsCopied] = useState(false);
|
const [isCopied, setIsCopied] = useState(false);
|
||||||
const [editingUser, setEditingUser] = useState(null);
|
const [editingUser, setEditingUser] = useState(null);
|
||||||
const [editForm, setEditForm] = useState({
|
const [editForm, setEditForm] = useState({
|
||||||
username: '',
|
|
||||||
first_name: '',
|
first_name: '',
|
||||||
last_name: '',
|
last_name: '',
|
||||||
email: '',
|
email: '',
|
||||||
@ -134,7 +133,6 @@ const AdminUsersPage = () => {
|
|||||||
setUsers(updatedUsers);
|
setUsers(updatedUsers);
|
||||||
setEditingUser(null);
|
setEditingUser(null);
|
||||||
setEditForm({
|
setEditForm({
|
||||||
username: '',
|
|
||||||
first_name: '',
|
first_name: '',
|
||||||
last_name: '',
|
last_name: '',
|
||||||
email: '',
|
email: '',
|
||||||
@ -152,7 +150,7 @@ const AdminUsersPage = () => {
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<Card className="page-container page-layout admin-page">
|
<Card className="page-container page-layout admin-page">
|
||||||
<CardHeader>
|
<CardHeader className="border-b">
|
||||||
<CardTitle>Gestion des comptes</CardTitle>
|
<CardTitle>Gestion des comptes</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@ -164,7 +162,7 @@ const AdminUsersPage = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="page-container page-layout admin-page flex h-full w-full">
|
<Card className="page-container page-layout admin-page flex h-full w-full">
|
||||||
<CardHeader>
|
<CardHeader className="border-b">
|
||||||
<CardTitle>Gestion des comptes</CardTitle>
|
<CardTitle>Gestion des comptes</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
@ -173,13 +171,13 @@ const AdminUsersPage = () => {
|
|||||||
<Alert variant="destructive" className="mb-4">
|
<Alert variant="destructive" className="mb-4">
|
||||||
<AlertDescription>{error}</AlertDescription>
|
<AlertDescription>{error}</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="overflow-x-auto mb-6">
|
<div className="overflow-x-auto mb-6">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Nom d'utilisateur</TableHead>
|
<TableHead>Nom</TableHead>
|
||||||
<TableHead>Couleur</TableHead>
|
<TableHead>Couleur</TableHead>
|
||||||
<TableHead>Rôle</TableHead>
|
<TableHead>Rôle</TableHead>
|
||||||
<TableHead>Inscrit le</TableHead>
|
<TableHead>Inscrit le</TableHead>
|
||||||
@ -189,7 +187,7 @@ const AdminUsersPage = () => {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{users.map((user) => (
|
{users.map((user) => (
|
||||||
<TableRow key={user.id}>
|
<TableRow key={user.id}>
|
||||||
<TableCell>{user.username}</TableCell>
|
<TableCell>{user.first_name} {user.last_name}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: user.color || '#0000ff' }}></div>
|
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: user.color || '#0000ff' }}></div>
|
||||||
@ -210,7 +208,6 @@ const AdminUsersPage = () => {
|
|||||||
} else {
|
} else {
|
||||||
setEditingUser(user.id);
|
setEditingUser(user.id);
|
||||||
setEditForm({
|
setEditForm({
|
||||||
username: user.username || '',
|
|
||||||
first_name: user.first_name || '',
|
first_name: user.first_name || '',
|
||||||
last_name: user.last_name || '',
|
last_name: user.last_name || '',
|
||||||
email: user.email || '',
|
email: user.email || '',
|
||||||
@ -227,18 +224,10 @@ const AdminUsersPage = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader className="border-b">
|
||||||
<DialogTitle>Modifier l'utilisateur</DialogTitle>
|
<DialogTitle>Modifier l'utilisateur</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor={`username-${user.id}`}>Nom d'utilisateur</Label>
|
|
||||||
<Input
|
|
||||||
id={`username-${user.id}`}
|
|
||||||
value={editForm.username}
|
|
||||||
onChange={(event) => setEditForm({ ...editForm, username: event.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor={`first-name-${user.id}`}>Prénom</Label>
|
<Label htmlFor={`first-name-${user.id}`}>Prénom</Label>
|
||||||
|
|||||||
@ -10,7 +10,7 @@ const HomePage = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full">
|
<div className="flex h-full w-full">
|
||||||
<Card className="mx-auto h-full w-full">
|
<Card className="mx-auto h-full w-full">
|
||||||
<CardHeader>
|
<CardHeader className="border-b">
|
||||||
<CardTitle>Profil Utilisateur</CardTitle>
|
<CardTitle>Profil Utilisateur</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
|
|||||||
@ -32,7 +32,7 @@ const LoginPage = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="auth-page page-layout flex h-full w-full">
|
<div className="auth-page page-layout flex h-full w-full">
|
||||||
<Card className="flex h-full w-full">
|
<Card className="flex h-full w-full">
|
||||||
<CardHeader>
|
<CardHeader className="border-b">
|
||||||
<CardTitle>Connexion</CardTitle>
|
<CardTitle>Connexion</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
|||||||
@ -47,7 +47,7 @@ const RegisterPage = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="auth-page page-layout flex h-full w-full">
|
<div className="auth-page page-layout flex h-full w-full">
|
||||||
<Card className="flex h-full w-full">
|
<Card className="flex h-full w-full">
|
||||||
<CardHeader>
|
<CardHeader className="border-b">
|
||||||
<CardTitle>Lien invalide</CardTitle>
|
<CardTitle>Lien invalide</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@ -62,7 +62,7 @@ const RegisterPage = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="auth-page page-layout flex h-full w-full">
|
<div className="auth-page page-layout flex h-full w-full">
|
||||||
<Card className="flex h-full w-full">
|
<Card className="flex h-full w-full">
|
||||||
<CardHeader>
|
<CardHeader className="border-b">
|
||||||
<CardTitle>Compte créé</CardTitle>
|
<CardTitle>Compte créé</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@ -79,7 +79,7 @@ const RegisterPage = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="auth-page page-layout">
|
<div className="auth-page page-layout">
|
||||||
<Card className="w-full max-w-md mx-auto">
|
<Card className="w-full max-w-md mx-auto">
|
||||||
<CardHeader>
|
<CardHeader className="border-b">
|
||||||
<CardTitle>Créer un compte</CardTitle>
|
<CardTitle>Créer un compte</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
|||||||
@ -10,7 +10,203 @@ import { Alert, AlertDescription } from '@/components/ui/alert';
|
|||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
import { Calendar } from '@/components/ui/calendar';
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@/components/ui/popover';
|
||||||
|
import { CalendarDays, Clock } from 'lucide-react';
|
||||||
|
|
||||||
|
|
||||||
|
const parseDateValue = (value) => {
|
||||||
|
if (!value) return undefined;
|
||||||
|
|
||||||
|
const [year, month, day] = value.split('-').map(Number);
|
||||||
|
|
||||||
|
if (!year || !month || !day) return undefined;
|
||||||
|
|
||||||
|
return new Date(year, month - 1, day);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDateValue = (date) => {
|
||||||
|
if (!date) return '';
|
||||||
|
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDateLabel = (value) => {
|
||||||
|
const date = parseDateValue(value);
|
||||||
|
|
||||||
|
if (!date) return 'Choisir une date';
|
||||||
|
|
||||||
|
return date.toLocaleDateString('fr-FR', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const DatePicker = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
min,
|
||||||
|
placeholder = 'Choisir une date',
|
||||||
|
}) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const selectedDate = parseDateValue(value);
|
||||||
|
const minDate = parseDateValue(min);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const TimePicker = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = 'Choisir une heure',
|
||||||
|
}) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const [hours = '', minutes = ''] = value ? value.split(':') : [];
|
||||||
|
|
||||||
|
const setTimePart = (part, nextValue) => {
|
||||||
|
const nextHours = part === 'hours' ? nextValue : hours || '00';
|
||||||
|
const nextMinutes = part === 'minutes' ? nextValue : minutes || '00';
|
||||||
|
|
||||||
|
onChange(`${nextHours}:${nextMinutes}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hourOptions = Array.from({ length: 24 }, (_, index) =>
|
||||||
|
String(index).padStart(2, '0')
|
||||||
|
);
|
||||||
|
|
||||||
|
const minuteOptions = Array.from({ length: 60 }, (_, index) =>
|
||||||
|
String(index).padStart(2, '0')
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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 ? '' : 'text-muted-foreground'}>
|
||||||
|
{value || placeholder}
|
||||||
|
</span>
|
||||||
|
</PopoverTrigger>
|
||||||
|
|
||||||
|
<PopoverContent className="w-[280px] p-3" align="start">
|
||||||
|
<div className="mb-3 flex items-center justify-between border-b pb-3">
|
||||||
|
<span className="text-sm font-medium">Choisir une heure</span>
|
||||||
|
<span className="rounded-md bg-muted px-2 py-1 text-sm font-medium tabular-nums">
|
||||||
|
{value || '--:--'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-xs font-medium text-muted-foreground">
|
||||||
|
Heure
|
||||||
|
</p>
|
||||||
|
<div className="grid max-h-56 grid-cols-4 gap-1 overflow-y-auto pr-1">
|
||||||
|
{hourOptions.map((hour) => (
|
||||||
|
<Button
|
||||||
|
key={hour}
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={hours === hour ? 'default' : 'ghost'}
|
||||||
|
className="h-8 px-0 tabular-nums"
|
||||||
|
onClick={() => setTimePart('hours', hour)}
|
||||||
|
>
|
||||||
|
{hour}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-xs font-medium text-muted-foreground">
|
||||||
|
Minutes
|
||||||
|
</p>
|
||||||
|
<div className="grid max-h-56 grid-cols-4 gap-1 overflow-y-auto pr-1">
|
||||||
|
{minuteOptions.map((minute) => (
|
||||||
|
<Button
|
||||||
|
key={minute}
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={minutes === minute ? 'default' : 'ghost'}
|
||||||
|
className="h-8 px-0 tabular-nums"
|
||||||
|
onClick={() => setTimePart('minutes', minute)}
|
||||||
|
>
|
||||||
|
{minute}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 border-t pt-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
className="w-full"
|
||||||
|
disabled={!value}
|
||||||
|
onClick={() => {
|
||||||
|
onChange('');
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Effacer
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const TimeClockPage = () => {
|
const TimeClockPage = () => {
|
||||||
const videoRef = useRef(null);
|
const videoRef = useRef(null);
|
||||||
@ -23,6 +219,8 @@ const TimeClockPage = () => {
|
|||||||
const [timeEntries, setTimeEntries] = useState([]);
|
const [timeEntries, setTimeEntries] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [isScannerDialogOpen, setIsScannerDialogOpen] = useState(false);
|
const [isScannerDialogOpen, setIsScannerDialogOpen] = useState(false);
|
||||||
|
const [cameraPermissionError, setCameraPermissionError] = useState(false);
|
||||||
|
const scannerSessionRef = useRef(0);
|
||||||
const [newEntry, setNewEntry] = useState({
|
const [newEntry, setNewEntry] = useState({
|
||||||
date: new Date().toISOString().split('T')[0],
|
date: new Date().toISOString().split('T')[0],
|
||||||
end_date: new Date().toISOString().split('T')[0],
|
end_date: new Date().toISOString().split('T')[0],
|
||||||
@ -150,35 +348,21 @@ const TimeClockPage = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const scannerSessionRef = useRef(0);
|
|
||||||
|
|
||||||
const stopScanner = () => {
|
const stopScanner = () => {
|
||||||
// Invalide immédiatement toute session de scan en cours
|
|
||||||
scannerSessionRef.current += 1;
|
scannerSessionRef.current += 1;
|
||||||
|
|
||||||
// Arrêter les contrôles ZXing
|
|
||||||
if (qrControlsRef.current) {
|
if (qrControlsRef.current) {
|
||||||
try {
|
try {
|
||||||
qrControlsRef.current.stop();
|
qrControlsRef.current.stop();
|
||||||
} catch (error) {
|
} catch (stopError) {
|
||||||
console.warn('Impossible d’arrêter le scanner QR.', error);
|
console.warn('Arrêt du lecteur QR déjà effectué.', stopError);
|
||||||
}
|
}
|
||||||
|
|
||||||
qrControlsRef.current = null;
|
qrControlsRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Arrêter explicitement la caméra
|
|
||||||
if (videoRef.current?.srcObject) {
|
if (videoRef.current?.srcObject) {
|
||||||
const stream = videoRef.current.srcObject;
|
const stream = videoRef.current.srcObject;
|
||||||
|
stream.getTracks().forEach((track) => track.stop());
|
||||||
stream.getTracks().forEach((track) => {
|
|
||||||
try {
|
|
||||||
track.stop();
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Impossible d’arrêter la piste caméra.', error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
videoRef.current.srcObject = null;
|
videoRef.current.srcObject = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -189,15 +373,10 @@ const TimeClockPage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleQrResult = async (code, sessionId) => {
|
const handleQrResult = async (code, sessionId) => {
|
||||||
// Si cette session n’est plus active, on ignore le résultat
|
if (sessionId !== scannerSessionRef.current) return;
|
||||||
if (scannerSessionRef.current !== sessionId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fermer immédiatement la caméra
|
|
||||||
closeScanner();
|
|
||||||
|
|
||||||
setScanConflict(null);
|
setScanConflict(null);
|
||||||
|
closeScanner();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await timeApi.scanTimeEntry(code);
|
const response = await timeApi.scanTimeEntry(code);
|
||||||
@ -218,15 +397,12 @@ const TimeClockPage = () => {
|
|||||||
|
|
||||||
const resolveScanConflict = async (resolution) => {
|
const resolveScanConflict = async (resolution) => {
|
||||||
if (!scanConflict) return;
|
if (!scanConflict) return;
|
||||||
|
|
||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await timeApi.scanTimeEntry(scanConflict.code, {
|
const response = await timeApi.scanTimeEntry(scanConflict.code, {
|
||||||
resolution,
|
resolution,
|
||||||
time: conflictTime,
|
time: conflictTime,
|
||||||
});
|
});
|
||||||
|
|
||||||
setScanMessage(response.message);
|
setScanMessage(response.message);
|
||||||
setScanConflict(null);
|
setScanConflict(null);
|
||||||
await loadTimeEntries();
|
await loadTimeEntries();
|
||||||
@ -235,35 +411,29 @@ const TimeClockPage = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Nettoyage lors du démontage
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
stopScanner();
|
stopScanner();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Gestion du scanner QR
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isScannerDialogOpen) {
|
if (!isScannerDialogOpen) return undefined;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionId = scannerSessionRef.current;
|
const sessionId = scannerSessionRef.current;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let reader = null;
|
let reader = null;
|
||||||
|
|
||||||
const startReader = async () => {
|
const startReader = async () => {
|
||||||
// Attendre que DialogContent soit réellement monté
|
|
||||||
await new Promise((resolve) => {
|
await new Promise((resolve) => {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
requestAnimationFrame(resolve);
|
requestAnimationFrame(resolve);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Le dialog a pu être fermé pendant l’attente
|
|
||||||
if (
|
if (
|
||||||
cancelled ||
|
cancelled ||
|
||||||
scannerSessionRef.current !== sessionId ||
|
sessionId !== scannerSessionRef.current ||
|
||||||
!videoRef.current
|
!videoRef.current
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
@ -284,29 +454,25 @@ const TimeClockPage = () => {
|
|||||||
if (
|
if (
|
||||||
!result ||
|
!result ||
|
||||||
cancelled ||
|
cancelled ||
|
||||||
scannerSessionRef.current !== sessionId
|
sessionId !== scannerSessionRef.current
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
handleQrResult(result.getText(), sessionId);
|
handleQrResult(result.getText(), sessionId);
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// La fermeture peut avoir eu lieu pendant l’initialisation
|
|
||||||
if (
|
if (
|
||||||
cancelled ||
|
cancelled ||
|
||||||
scannerSessionRef.current !== sessionId
|
sessionId !== scannerSessionRef.current ||
|
||||||
|
!isScannerDialogOpen
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
controls.stop();
|
controls.stop();
|
||||||
} catch (error) {
|
} catch (stopError) {
|
||||||
console.warn(
|
console.warn('Arrêt du scanner ignoré.', stopError);
|
||||||
'Impossible d’arrêter le scanner après annulation.',
|
|
||||||
error
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -314,16 +480,28 @@ const TimeClockPage = () => {
|
|||||||
} catch (scannerError) {
|
} catch (scannerError) {
|
||||||
if (
|
if (
|
||||||
cancelled ||
|
cancelled ||
|
||||||
scannerSessionRef.current !== sessionId
|
sessionId !== scannerSessionRef.current
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error('Erreur scanner QR:', scannerError);
|
console.error('Erreur scanner QR:', scannerError);
|
||||||
|
|
||||||
setError(
|
const permissionDenied =
|
||||||
'Impossible d’accéder à la caméra. Vérifiez les permissions et utilisez HTTPS sur le réseau.'
|
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();
|
closeScanner();
|
||||||
}
|
}
|
||||||
@ -334,50 +512,17 @@ const TimeClockPage = () => {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
|
||||||
// Invalide cette session
|
|
||||||
if (scannerSessionRef.current === sessionId) {
|
|
||||||
scannerSessionRef.current += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
reader?.reset();
|
reader?.reset();
|
||||||
} catch (error) {
|
} catch (resetError) {
|
||||||
console.warn(
|
console.warn(
|
||||||
'Réinitialisation du lecteur QR ignorée.',
|
'Réinitialisation du lecteur QR ignorée.',
|
||||||
error
|
resetError,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Arrêter les contrôles ZXing
|
if (sessionId === scannerSessionRef.current) {
|
||||||
if (qrControlsRef.current) {
|
stopScanner();
|
||||||
try {
|
|
||||||
qrControlsRef.current.stop();
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(
|
|
||||||
'Impossible d’arrêter les contrôles QR.',
|
|
||||||
error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
qrControlsRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Arrêter explicitement toutes les pistes caméra
|
|
||||||
if (videoRef.current?.srcObject) {
|
|
||||||
const stream = videoRef.current.srcObject;
|
|
||||||
|
|
||||||
stream.getTracks().forEach((track) => {
|
|
||||||
try {
|
|
||||||
track.stop();
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(
|
|
||||||
'Impossible d’arrêter la piste caméra.',
|
|
||||||
error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
videoRef.current.srcObject = null;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [isScannerDialogOpen]);
|
}, [isScannerDialogOpen]);
|
||||||
@ -386,6 +531,7 @@ const TimeClockPage = () => {
|
|||||||
setError('');
|
setError('');
|
||||||
setScanMessage('');
|
setScanMessage('');
|
||||||
setScanConflict(null);
|
setScanConflict(null);
|
||||||
|
setCameraPermissionError(false);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!window.isSecureContext &&
|
!window.isSecureContext &&
|
||||||
@ -393,7 +539,7 @@ const TimeClockPage = () => {
|
|||||||
window.location.hostname !== '192.168.1.19'
|
window.location.hostname !== '192.168.1.19'
|
||||||
) {
|
) {
|
||||||
setError(
|
setError(
|
||||||
'La caméra nécessite une connexion HTTPS sur cette adresse réseau.'
|
'La caméra nécessite une connexion HTTPS sur cette adresse réseau.',
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -403,12 +549,62 @@ const TimeClockPage = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nouvelle session de scanner
|
|
||||||
scannerSessionRef.current += 1;
|
scannerSessionRef.current += 1;
|
||||||
|
|
||||||
setIsScannerDialogOpen(true);
|
setIsScannerDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const requestCameraPermission = async () => {
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
if (
|
||||||
|
!window.isSecureContext &&
|
||||||
|
window.location.hostname !== 'localhost' &&
|
||||||
|
window.location.hostname !== '192.168.1.19'
|
||||||
|
) {
|
||||||
|
setError(
|
||||||
|
'La caméra nécessite une connexion HTTPS sur cette adresse réseau.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!navigator.mediaDevices?.getUserMedia) {
|
||||||
|
setError('Ce navigateur ne permet pas l’accès à la caméra.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
video: {
|
||||||
|
facingMode: { ideal: 'environment' },
|
||||||
|
},
|
||||||
|
audio: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.getTracks().forEach((track) => track.stop());
|
||||||
|
setCameraPermissionError(false);
|
||||||
|
startScanner();
|
||||||
|
} catch (permissionError) {
|
||||||
|
console.error(
|
||||||
|
'Erreur lors de la demande de permission caméra:',
|
||||||
|
permissionError,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
permissionError?.name === 'NotAllowedError' ||
|
||||||
|
permissionError?.name === 'PermissionDeniedError'
|
||||||
|
) {
|
||||||
|
setCameraPermissionError(true);
|
||||||
|
setError(
|
||||||
|
'La caméra est toujours refusée. Autorisez-la dans les paramètres du site, puis cliquez à nouveau sur « Autoriser la caméra ».',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setError(
|
||||||
|
'Impossible d’obtenir l’accès à la caméra. Vérifiez les permissions du navigateur et la connexion HTTPS.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const formatTime = (timeString) => {
|
const formatTime = (timeString) => {
|
||||||
if (!timeString) return '00h00';
|
if (!timeString) return '00h00';
|
||||||
const [hours, minutes] = timeString.split(':');
|
const [hours, minutes] = timeString.split(':');
|
||||||
@ -456,7 +652,7 @@ const TimeClockPage = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="flex h-full w-full">
|
<Card className="flex h-full w-full">
|
||||||
<CardHeader>
|
<CardHeader className="border-b">
|
||||||
<CardTitle>Pointage</CardTitle>
|
<CardTitle>Pointage</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@ -466,54 +662,16 @@ const TimeClockPage = () => {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Dialog for QR scanner preview */}
|
{cameraPermissionError && (
|
||||||
<Dialog
|
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
open={isScannerDialogOpen}
|
<Button type="button" variant="secondary" onClick={requestCameraPermission}>
|
||||||
onOpenChange={(open) => {
|
Autoriser la caméra
|
||||||
if (open) {
|
</Button>
|
||||||
setIsScannerDialogOpen(true);
|
<p className="text-sm text-muted-foreground">
|
||||||
} else {
|
Si le navigateur a déjà bloqué la caméra, autorisez-la dans les paramètres du site puis cliquez ici pour réessayer.
|
||||||
closeScanner();
|
</p>
|
||||||
}
|
</div>
|
||||||
}}
|
)}
|
||||||
>
|
|
||||||
<Button
|
|
||||||
onClick={startScanner}
|
|
||||||
variant="default"
|
|
||||||
className="mb-4"
|
|
||||||
>
|
|
||||||
{isAdmin ? 'Pointer avec un QR code' : 'Pointer'}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<DialogContent className="max-w-2xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{scanMessage && (
|
{scanMessage && (
|
||||||
<Alert variant="success" className="mb-4">
|
<Alert variant="success" className="mb-4">
|
||||||
@ -523,18 +681,15 @@ const TimeClockPage = () => {
|
|||||||
|
|
||||||
{scanConflict && (
|
{scanConflict && (
|
||||||
<Card className="mb-4">
|
<Card className="mb-4">
|
||||||
<CardHeader>
|
<CardHeader className="border-b">
|
||||||
<CardTitle>Conflit de pointage</CardTitle>
|
<CardTitle>Conflit de pointage</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="mb-2">{scanConflict.message}</p>
|
<p className="mb-2">{scanConflict.message}</p>
|
||||||
<Label htmlFor="conflict-time">Heure à utiliser</Label>
|
<Label htmlFor="conflict-time">Heure à utiliser</Label>
|
||||||
<Input
|
<TimePicker
|
||||||
id="conflict-time"
|
|
||||||
type="time"
|
|
||||||
value={conflictTime}
|
value={conflictTime}
|
||||||
onChange={(event) => setConflictTime(event.target.value)}
|
onChange={setConflictTime}
|
||||||
className="mb-2"
|
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
@ -555,10 +710,10 @@ const TimeClockPage = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Time tracking section */}
|
{/* Time tracking section */}
|
||||||
<div className={isAdmin ? 'admin-time-layout' : undefined}>
|
<div className="w-full">
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<Card className="mb-4">
|
<Card className="mb-4">
|
||||||
<CardHeader>
|
<CardHeader className="border-b">
|
||||||
<CardTitle>
|
<CardTitle>
|
||||||
{editingEntry ? 'Modifier le pointage' : 'Ajouter un pointage manuel'}
|
{editingEntry ? 'Modifier le pointage' : 'Ajouter un pointage manuel'}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
@ -566,75 +721,69 @@ const TimeClockPage = () => {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
{editingEntry ? (
|
{editingEntry ? (
|
||||||
<form onSubmit={handleUpdateEntry}>
|
<form onSubmit={handleUpdateEntry}>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
<div className="mb-4 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="edit-start-time">Heure d'entrée :</Label>
|
<Label htmlFor="edit-start-time">Heure d'entrée :</Label>
|
||||||
<Input
|
<TimePicker
|
||||||
id="edit-start-time"
|
|
||||||
type="time"
|
|
||||||
value={editForm.start_time}
|
value={editForm.start_time}
|
||||||
onChange={(e) => setEditForm({ ...editForm, start_time: e.target.value })}
|
onChange={(value) =>
|
||||||
|
setEditForm({ ...editForm, start_time: value })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="edit-end-time">Heure de sortie :</Label>
|
<Label htmlFor="edit-end-time">Heure de sortie :</Label>
|
||||||
<Input
|
<TimePicker
|
||||||
id="edit-end-time"
|
|
||||||
type="time"
|
|
||||||
value={editForm.end_time}
|
value={editForm.end_time}
|
||||||
onChange={(e) => setEditForm({ ...editForm, end_time: e.target.value })}
|
onChange={(value) =>
|
||||||
|
setEditForm({ ...editForm, end_time: value })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button type="submit">Enregistrer</Button>
|
<Button type="submit">Enregistrer</Button>
|
||||||
<Button
|
<Button type="button" variant="secondary" onClick={() => setEditingEntry(null)}>Annuler</Button>
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => setEditingEntry(null)}
|
|
||||||
>
|
|
||||||
Annuler
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={handleAddEntry}>
|
<form onSubmit={handleAddEntry}>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
<div className="mb-4 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<Label>Date:</Label>
|
<Label>Date:</Label>
|
||||||
<Input
|
<DatePicker
|
||||||
type="date"
|
|
||||||
value={newEntry.date}
|
value={newEntry.date}
|
||||||
onChange={(e) => setNewEntry({...newEntry, date: e.target.value})}
|
onChange={(value) =>
|
||||||
required
|
setNewEntry({ ...newEntry, date: value })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label>Date de sortie:</Label>
|
<Label>Date de sortie:</Label>
|
||||||
<Input
|
<DatePicker
|
||||||
type="date"
|
|
||||||
value={newEntry.end_date}
|
value={newEntry.end_date}
|
||||||
min={newEntry.date}
|
min={newEntry.date}
|
||||||
onChange={(e) => setNewEntry({...newEntry, end_date: e.target.value})}
|
onChange={(value) =>
|
||||||
required
|
setNewEntry({ ...newEntry, end_date: value })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label>Heure d'entrée:</Label>
|
<Label>Heure d'entrée:</Label>
|
||||||
<Input
|
<TimePicker
|
||||||
type="time"
|
|
||||||
value={newEntry.start_time}
|
value={newEntry.start_time}
|
||||||
onChange={(e) => setNewEntry({...newEntry, start_time: e.target.value})}
|
onChange={(value) =>
|
||||||
required
|
setNewEntry({ ...newEntry, start_time: value })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label>Heure de sortie:</Label>
|
<Label>Heure de sortie:</Label>
|
||||||
<Input
|
<TimePicker
|
||||||
type="time"
|
|
||||||
value={newEntry.end_time}
|
value={newEntry.end_time}
|
||||||
onChange={(e) => setNewEntry({...newEntry, end_time: e.target.value})}
|
onChange={(value) =>
|
||||||
required
|
setNewEntry({ ...newEntry, end_time: value })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -645,73 +794,102 @@ const TimeClockPage = () => {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Time entries list */}
|
<div className="w-full">
|
||||||
<Card>
|
{/* Pointage uniquement disponible sur mobile */}
|
||||||
<CardHeader>
|
<div className="flex flex-col items-center justify-center py-6 md:hidden">
|
||||||
<CardTitle>Historique des pointages</CardTitle>
|
<Dialog
|
||||||
</CardHeader>
|
open={isScannerDialogOpen}
|
||||||
<CardContent>
|
onOpenChange={(open) => {
|
||||||
{loading ? (
|
if (open) {
|
||||||
<p>Chargement des pointages...</p>
|
setIsScannerDialogOpen(true);
|
||||||
) : timeEntries.length === 0 ? (
|
} else {
|
||||||
<p>Aucun pointage enregistré</p>
|
closeScanner();
|
||||||
) : (
|
}
|
||||||
<div className="overflow-x-auto">
|
}}
|
||||||
<Table>
|
>
|
||||||
<TableHeader>
|
<Button onClick={startScanner} variant="default">
|
||||||
<TableRow>
|
{isAdmin ? 'Pointer avec un QR code' : 'Pointer'}
|
||||||
{isAdmin && <TableHead>Utilisateur</TableHead>}
|
</Button>
|
||||||
<TableHead>Date</TableHead>
|
|
||||||
<TableHead>Entrée</TableHead>
|
<DialogContent className="max-w-2xl">
|
||||||
<TableHead>Sortie</TableHead>
|
<DialogHeader className="border-b">
|
||||||
<TableHead>Durée</TableHead>
|
<DialogTitle>Aperçu de la caméra</DialogTitle>
|
||||||
{isAdmin && <TableHead>Actions</TableHead>}
|
</DialogHeader>
|
||||||
</TableRow>
|
<div className="scanner-panel">
|
||||||
</TableHeader>
|
<video
|
||||||
<TableBody>
|
ref={videoRef}
|
||||||
{getDisplayTimeEntries().map((entry) => (
|
className="scanner-video h-auto w-full"
|
||||||
<TableRow key={entry.id}>
|
playsInline
|
||||||
{isAdmin && (
|
muted
|
||||||
<TableCell>{entry.user}</TableCell>
|
autoPlay
|
||||||
)}
|
/>
|
||||||
<TableCell>{formatDate(entry.date)}</TableCell>
|
<div className="scanner-frame" aria-hidden="true" />
|
||||||
<TableCell>{formatTime(entry.start_time)}</TableCell>
|
<Button onClick={closeScanner} variant="secondary" className="mt-2">
|
||||||
<TableCell>{formatTime(entry.end_time)}</TableCell>
|
Annuler
|
||||||
<TableCell>
|
</Button>
|
||||||
<Badge variant="secondary">
|
</div>
|
||||||
{formatDuration(entry.duration)}
|
</DialogContent>
|
||||||
</Badge>
|
</Dialog>
|
||||||
</TableCell>
|
</div>
|
||||||
{isAdmin && (
|
|
||||||
<TableCell>
|
{/* Sur ordinateur, le pointage est volontairement indisponible */}
|
||||||
<div className="flex gap-2">
|
{!isAdmin &&
|
||||||
<Button
|
<div className="hidden py-6 text-center md:block">
|
||||||
onClick={() => handleEditEntry(entry)}
|
<p className="text-sm text-muted-foreground">
|
||||||
variant="secondary"
|
Le pointage est indisponible sur ordinateur.
|
||||||
size="sm"
|
</p>
|
||||||
>
|
</div>
|
||||||
Modifier
|
}
|
||||||
</Button>
|
|
||||||
<ConfirmDialog
|
<Card>
|
||||||
title="Supprimer le pointage"
|
<CardHeader className="border-b">
|
||||||
description="Êtes-vous sûr de vouloir supprimer ce pointage ?"
|
<CardTitle>Historique des pointages</CardTitle>
|
||||||
onConfirm={() => handleDeleteEntry(entry.id)}
|
</CardHeader>
|
||||||
>
|
<CardContent>
|
||||||
<Button variant="destructive" size="sm">
|
{loading ? (
|
||||||
Supprimer
|
<p>Chargement des pointages...</p>
|
||||||
</Button>
|
) : timeEntries.length === 0 ? (
|
||||||
</ConfirmDialog>
|
<p>Aucun pointage enregistré</p>
|
||||||
</div>
|
) : (
|
||||||
</TableCell>
|
<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>
|
||||||
|
{isAdmin && <TableHead>Actions</TableHead>}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
</TableHeader>
|
||||||
</TableBody>
|
<TableBody>
|
||||||
</Table>
|
{getDisplayTimeEntries().map((entry) => (
|
||||||
</div>
|
<TableRow key={entry.id}>
|
||||||
)}
|
{isAdmin && <TableCell>{entry.first_name} {entry.last_name}</TableCell>}
|
||||||
</CardContent>
|
<TableCell>{formatDate(entry.date)}</TableCell>
|
||||||
</Card>
|
<TableCell>{formatTime(entry.start_time)}</TableCell>
|
||||||
|
<TableCell>{formatTime(entry.end_time)}</TableCell>
|
||||||
|
<TableCell><Badge variant="secondary">{formatDuration(entry.duration)}</Badge></TableCell>
|
||||||
|
{isAdmin && (
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={() => handleEditEntry(entry)} variant="secondary" size="sm">Modifier</Button>
|
||||||
|
<ConfirmDialog title="Supprimer le pointage" description="Êtes-vous sûr de vouloir supprimer ce pointage ?" onConfirm={() => handleDeleteEntry(entry.id)}>
|
||||||
|
<Button variant="destructive" size="sm">Supprimer</Button>
|
||||||
|
</ConfirmDialog>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@ -189,8 +189,8 @@ const WeeklySummaryPage = ({ userId }) => {
|
|||||||
).padStart(2, '0')}`;
|
).padStart(2, '0')}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getUserColor = (username) => {
|
const getUserColor = (first_name) => {
|
||||||
const hash = [...username].reduce(
|
const hash = [...first_name].reduce(
|
||||||
(total, character) => total + character.charCodeAt(0),
|
(total, character) => total + character.charCodeAt(0),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
@ -256,15 +256,15 @@ const WeeklySummaryPage = ({ userId }) => {
|
|||||||
start,
|
start,
|
||||||
end,
|
end,
|
||||||
users: activeAttendances
|
users: activeAttendances
|
||||||
.map(({ attendance }) => attendance.username)
|
.map(({ attendance }) => attendance.first_name)
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.filter(
|
.filter(
|
||||||
(username, usernameIndex, names) =>
|
(first_name, first_nameIndex, names) =>
|
||||||
names.indexOf(username) === usernameIndex,
|
names.indexOf(first_name) === first_nameIndex,
|
||||||
)
|
)
|
||||||
.map((username) => ({
|
.map((first_name) => ({
|
||||||
username,
|
first_name,
|
||||||
color: getUserColor(username),
|
color: getUserColor(first_name),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
@ -283,22 +283,22 @@ const WeeklySummaryPage = ({ userId }) => {
|
|||||||
getTimeValue(attendance, 'end_time'),
|
getTimeValue(attendance, 'end_time'),
|
||||||
);
|
);
|
||||||
|
|
||||||
const username = attendance.username;
|
const first_name = attendance.first_name;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
start === null ||
|
start === null ||
|
||||||
end === null ||
|
end === null ||
|
||||||
end <= start ||
|
end <= start ||
|
||||||
!username
|
!first_name
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentColumn = columns.get(username);
|
const currentColumn = columns.get(first_name);
|
||||||
|
|
||||||
columns.set(username, {
|
columns.set(first_name, {
|
||||||
username,
|
first_name,
|
||||||
color: getUserColor(username),
|
color: getUserColor(first_name),
|
||||||
start: currentColumn
|
start: currentColumn
|
||||||
? Math.min(currentColumn.start, start)
|
? Math.min(currentColumn.start, start)
|
||||||
: start,
|
: start,
|
||||||
@ -496,16 +496,16 @@ const WeeklySummaryPage = ({ userId }) => {
|
|||||||
const legendUsers = [
|
const legendUsers = [
|
||||||
...new Set(
|
...new Set(
|
||||||
attendances
|
attendances
|
||||||
.map((attendance) => attendance.username)
|
.map((attendance) => attendance.first_name)
|
||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
.sort((first, second) =>
|
.sort((first, second) =>
|
||||||
first.localeCompare(second, 'fr'),
|
first.localeCompare(second, 'fr'),
|
||||||
)
|
)
|
||||||
.map((username) => ({
|
.map((first_name) => ({
|
||||||
username,
|
first_name,
|
||||||
color: getUserColor(username),
|
color: getUserColor(first_name),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const displayDay = isMobile
|
const displayDay = isMobile
|
||||||
@ -627,9 +627,9 @@ const WeeklySummaryPage = ({ userId }) => {
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
{legendUsers.map(
|
{legendUsers.map(
|
||||||
({ username, color }) => (
|
({ first_name, color }) => (
|
||||||
<div
|
<div
|
||||||
key={username}
|
key={first_name}
|
||||||
className="flex items-center gap-1"
|
className="flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@ -640,7 +640,7 @@ const WeeklySummaryPage = ({ userId }) => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
{username}
|
{first_name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@ -715,7 +715,7 @@ const WeeklySummaryPage = ({ userId }) => {
|
|||||||
({
|
({
|
||||||
start,
|
start,
|
||||||
end,
|
end,
|
||||||
username,
|
first_name,
|
||||||
color,
|
color,
|
||||||
}, columnIndex) => {
|
}, columnIndex) => {
|
||||||
const style =
|
const style =
|
||||||
@ -731,7 +731,7 @@ const WeeklySummaryPage = ({ userId }) => {
|
|||||||
<div
|
<div
|
||||||
key={`${formatDateKey(
|
key={`${formatDateKey(
|
||||||
displayDay,
|
displayDay,
|
||||||
)}-${username}`}
|
)}-${first_name}`}
|
||||||
className="absolute rounded-sm"
|
className="absolute rounded-sm"
|
||||||
style={{
|
style={{
|
||||||
...style,
|
...style,
|
||||||
@ -743,7 +743,7 @@ const WeeklySummaryPage = ({ userId }) => {
|
|||||||
backgroundColor:
|
backgroundColor:
|
||||||
color,
|
color,
|
||||||
}}
|
}}
|
||||||
title={`${username} : ${formatSegmentTime(
|
title={`${first_name} : ${formatSegmentTime(
|
||||||
start,
|
start,
|
||||||
)} - ${formatSegmentTime(
|
)} - ${formatSegmentTime(
|
||||||
end,
|
end,
|
||||||
@ -775,8 +775,8 @@ const WeeklySummaryPage = ({ userId }) => {
|
|||||||
users
|
users
|
||||||
.map(
|
.map(
|
||||||
({
|
({
|
||||||
username,
|
first_name,
|
||||||
}) => username,
|
}) => first_name,
|
||||||
)
|
)
|
||||||
.join(', ');
|
.join(', ');
|
||||||
|
|
||||||
@ -890,7 +890,7 @@ const WeeklySummaryPage = ({ userId }) => {
|
|||||||
({
|
({
|
||||||
start,
|
start,
|
||||||
end,
|
end,
|
||||||
username,
|
first_name,
|
||||||
color,
|
color,
|
||||||
}) => {
|
}) => {
|
||||||
const style =
|
const style =
|
||||||
@ -906,14 +906,14 @@ const WeeklySummaryPage = ({ userId }) => {
|
|||||||
<div
|
<div
|
||||||
key={`${formatDateKey(
|
key={`${formatDateKey(
|
||||||
day,
|
day,
|
||||||
)}-${username}`}
|
)}-${first_name}`}
|
||||||
className="absolute left-2 right-2 rounded-md border"
|
className="absolute left-2 right-2 rounded-md border"
|
||||||
style={{
|
style={{
|
||||||
...style,
|
...style,
|
||||||
backgroundColor: `${color}22`,
|
backgroundColor: `${color}22`,
|
||||||
borderColor: color,
|
borderColor: color,
|
||||||
}}
|
}}
|
||||||
title={`${username} : ${formatSegmentTime(
|
title={`${first_name} : ${formatSegmentTime(
|
||||||
start,
|
start,
|
||||||
)} - ${formatSegmentTime(
|
)} - ${formatSegmentTime(
|
||||||
end,
|
end,
|
||||||
@ -934,7 +934,7 @@ const WeeklySummaryPage = ({ userId }) => {
|
|||||||
color,
|
color,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{username}
|
{first_name}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-muted-foreground">
|
<div className="text-muted-foreground">
|
||||||
@ -972,9 +972,9 @@ const WeeklySummaryPage = ({ userId }) => {
|
|||||||
users
|
users
|
||||||
.map(
|
.map(
|
||||||
({
|
({
|
||||||
username,
|
first_name,
|
||||||
}) =>
|
}) =>
|
||||||
username,
|
first_name,
|
||||||
)
|
)
|
||||||
.join(', ');
|
.join(', ');
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user