nice
This commit is contained in:
parent
bb0f242dd5
commit
ba76763c24
@ -26,7 +26,6 @@ const AdminUsersPage = () => {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState(null);
|
||||
const [editForm, setEditForm] = useState({
|
||||
username: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
@ -134,7 +133,6 @@ const AdminUsersPage = () => {
|
||||
setUsers(updatedUsers);
|
||||
setEditingUser(null);
|
||||
setEditForm({
|
||||
username: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
@ -152,7 +150,7 @@ const AdminUsersPage = () => {
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="page-container page-layout admin-page">
|
||||
<CardHeader>
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle>Gestion des comptes</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@ -164,7 +162,7 @@ const AdminUsersPage = () => {
|
||||
|
||||
return (
|
||||
<Card className="page-container page-layout admin-page flex h-full w-full">
|
||||
<CardHeader>
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle>Gestion des comptes</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
@ -173,13 +171,13 @@ const AdminUsersPage = () => {
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Nom d'utilisateur</TableHead>
|
||||
<TableHead>Nom</TableHead>
|
||||
<TableHead>Couleur</TableHead>
|
||||
<TableHead>Rôle</TableHead>
|
||||
<TableHead>Inscrit le</TableHead>
|
||||
@ -189,7 +187,7 @@ const AdminUsersPage = () => {
|
||||
<TableBody>
|
||||
{users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>{user.username}</TableCell>
|
||||
<TableCell>{user.first_name} {user.last_name}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: user.color || '#0000ff' }}></div>
|
||||
@ -210,7 +208,6 @@ const AdminUsersPage = () => {
|
||||
} else {
|
||||
setEditingUser(user.id);
|
||||
setEditForm({
|
||||
username: user.username || '',
|
||||
first_name: user.first_name || '',
|
||||
last_name: user.last_name || '',
|
||||
email: user.email || '',
|
||||
@ -227,18 +224,10 @@ const AdminUsersPage = () => {
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogHeader className="border-b">
|
||||
<DialogTitle>Modifier l'utilisateur</DialogTitle>
|
||||
</DialogHeader>
|
||||
<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 gap-2">
|
||||
<Label htmlFor={`first-name-${user.id}`}>Prénom</Label>
|
||||
|
||||
@ -10,7 +10,7 @@ const HomePage = () => {
|
||||
return (
|
||||
<div className="flex h-full w-full">
|
||||
<Card className="mx-auto h-full w-full">
|
||||
<CardHeader>
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle>Profil Utilisateur</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
|
||||
@ -32,7 +32,7 @@ const LoginPage = () => {
|
||||
return (
|
||||
<div className="auth-page page-layout flex h-full w-full">
|
||||
<Card className="flex h-full w-full">
|
||||
<CardHeader>
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle>Connexion</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
||||
@ -47,7 +47,7 @@ const RegisterPage = () => {
|
||||
return (
|
||||
<div className="auth-page page-layout flex h-full w-full">
|
||||
<Card className="flex h-full w-full">
|
||||
<CardHeader>
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle>Lien invalide</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@ -62,7 +62,7 @@ const RegisterPage = () => {
|
||||
return (
|
||||
<div className="auth-page page-layout flex h-full w-full">
|
||||
<Card className="flex h-full w-full">
|
||||
<CardHeader>
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle>Compte créé</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@ -79,7 +79,7 @@ const RegisterPage = () => {
|
||||
return (
|
||||
<div className="auth-page page-layout">
|
||||
<Card className="w-full max-w-md mx-auto">
|
||||
<CardHeader>
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle>Créer un compte</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
||||
@ -10,7 +10,203 @@ 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, 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 videoRef = useRef(null);
|
||||
@ -23,6 +219,8 @@ const TimeClockPage = () => {
|
||||
const [timeEntries, setTimeEntries] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isScannerDialogOpen, setIsScannerDialogOpen] = useState(false);
|
||||
const [cameraPermissionError, setCameraPermissionError] = useState(false);
|
||||
const scannerSessionRef = useRef(0);
|
||||
const [newEntry, setNewEntry] = useState({
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
end_date: new Date().toISOString().split('T')[0],
|
||||
@ -150,35 +348,21 @@ const TimeClockPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const scannerSessionRef = useRef(0);
|
||||
|
||||
const stopScanner = () => {
|
||||
// Invalide immédiatement toute session de scan en cours
|
||||
scannerSessionRef.current += 1;
|
||||
|
||||
// Arrêter les contrôles ZXing
|
||||
if (qrControlsRef.current) {
|
||||
try {
|
||||
qrControlsRef.current.stop();
|
||||
} catch (error) {
|
||||
console.warn('Impossible d’arrêter le scanner QR.', error);
|
||||
} catch (stopError) {
|
||||
console.warn('Arrêt du lecteur QR déjà effectué.', stopError);
|
||||
}
|
||||
|
||||
qrControlsRef.current = null;
|
||||
}
|
||||
|
||||
// Arrêter explicitement la 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);
|
||||
}
|
||||
});
|
||||
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
videoRef.current.srcObject = null;
|
||||
}
|
||||
};
|
||||
@ -189,15 +373,10 @@ const TimeClockPage = () => {
|
||||
};
|
||||
|
||||
const handleQrResult = async (code, sessionId) => {
|
||||
// Si cette session n’est plus active, on ignore le résultat
|
||||
if (scannerSessionRef.current !== sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fermer immédiatement la caméra
|
||||
closeScanner();
|
||||
if (sessionId !== scannerSessionRef.current) return;
|
||||
|
||||
setScanConflict(null);
|
||||
closeScanner();
|
||||
|
||||
try {
|
||||
const response = await timeApi.scanTimeEntry(code);
|
||||
@ -218,15 +397,12 @@ const TimeClockPage = () => {
|
||||
|
||||
const resolveScanConflict = async (resolution) => {
|
||||
if (!scanConflict) return;
|
||||
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await timeApi.scanTimeEntry(scanConflict.code, {
|
||||
resolution,
|
||||
time: conflictTime,
|
||||
});
|
||||
|
||||
setScanMessage(response.message);
|
||||
setScanConflict(null);
|
||||
await loadTimeEntries();
|
||||
@ -235,35 +411,29 @@ const TimeClockPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Nettoyage lors du démontage
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stopScanner();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Gestion du scanner QR
|
||||
useEffect(() => {
|
||||
if (!isScannerDialogOpen) {
|
||||
return;
|
||||
}
|
||||
if (!isScannerDialogOpen) return undefined;
|
||||
|
||||
const sessionId = scannerSessionRef.current;
|
||||
let cancelled = false;
|
||||
let reader = null;
|
||||
|
||||
const startReader = async () => {
|
||||
// Attendre que DialogContent soit réellement monté
|
||||
await new Promise((resolve) => {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(resolve);
|
||||
});
|
||||
});
|
||||
|
||||
// Le dialog a pu être fermé pendant l’attente
|
||||
if (
|
||||
cancelled ||
|
||||
scannerSessionRef.current !== sessionId ||
|
||||
sessionId !== scannerSessionRef.current ||
|
||||
!videoRef.current
|
||||
) {
|
||||
return;
|
||||
@ -284,29 +454,25 @@ const TimeClockPage = () => {
|
||||
if (
|
||||
!result ||
|
||||
cancelled ||
|
||||
scannerSessionRef.current !== sessionId
|
||||
sessionId !== scannerSessionRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
handleQrResult(result.getText(), sessionId);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// La fermeture peut avoir eu lieu pendant l’initialisation
|
||||
if (
|
||||
cancelled ||
|
||||
scannerSessionRef.current !== sessionId
|
||||
sessionId !== scannerSessionRef.current ||
|
||||
!isScannerDialogOpen
|
||||
) {
|
||||
try {
|
||||
controls.stop();
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'Impossible d’arrêter le scanner après annulation.',
|
||||
error
|
||||
);
|
||||
} catch (stopError) {
|
||||
console.warn('Arrêt du scanner ignoré.', stopError);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@ -314,16 +480,28 @@ const TimeClockPage = () => {
|
||||
} catch (scannerError) {
|
||||
if (
|
||||
cancelled ||
|
||||
scannerSessionRef.current !== sessionId
|
||||
sessionId !== scannerSessionRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('Erreur scanner QR:', scannerError);
|
||||
|
||||
setError(
|
||||
'Impossible d’accéder à la caméra. Vérifiez les permissions et utilisez HTTPS sur le réseau.'
|
||||
);
|
||||
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();
|
||||
}
|
||||
@ -334,50 +512,17 @@ const TimeClockPage = () => {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
|
||||
// Invalide cette session
|
||||
if (scannerSessionRef.current === sessionId) {
|
||||
scannerSessionRef.current += 1;
|
||||
}
|
||||
|
||||
try {
|
||||
reader?.reset();
|
||||
} catch (error) {
|
||||
} catch (resetError) {
|
||||
console.warn(
|
||||
'Réinitialisation du lecteur QR ignorée.',
|
||||
error
|
||||
resetError,
|
||||
);
|
||||
}
|
||||
|
||||
// Arrêter les contrôles ZXing
|
||||
if (qrControlsRef.current) {
|
||||
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;
|
||||
if (sessionId === scannerSessionRef.current) {
|
||||
stopScanner();
|
||||
}
|
||||
};
|
||||
}, [isScannerDialogOpen]);
|
||||
@ -386,6 +531,7 @@ const TimeClockPage = () => {
|
||||
setError('');
|
||||
setScanMessage('');
|
||||
setScanConflict(null);
|
||||
setCameraPermissionError(false);
|
||||
|
||||
if (
|
||||
!window.isSecureContext &&
|
||||
@ -393,7 +539,7 @@ const TimeClockPage = () => {
|
||||
window.location.hostname !== '192.168.1.19'
|
||||
) {
|
||||
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;
|
||||
}
|
||||
@ -403,12 +549,62 @@ const TimeClockPage = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Nouvelle session de scanner
|
||||
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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (timeString) => {
|
||||
if (!timeString) return '00h00';
|
||||
const [hours, minutes] = timeString.split(':');
|
||||
@ -456,7 +652,7 @@ const TimeClockPage = () => {
|
||||
|
||||
return (
|
||||
<Card className="flex h-full w-full">
|
||||
<CardHeader>
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle>Pointage</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@ -466,54 +662,16 @@ const TimeClockPage = () => {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Dialog for QR scanner preview */}
|
||||
<Dialog
|
||||
open={isScannerDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
setIsScannerDialogOpen(true);
|
||||
} else {
|
||||
closeScanner();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
{cameraPermissionError && (
|
||||
<div className="mb-4 flex 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">
|
||||
@ -523,18 +681,15 @@ const TimeClockPage = () => {
|
||||
|
||||
{scanConflict && (
|
||||
<Card className="mb-4">
|
||||
<CardHeader>
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle>Conflit de pointage</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="mb-2">{scanConflict.message}</p>
|
||||
<Label htmlFor="conflict-time">Heure à utiliser</Label>
|
||||
<Input
|
||||
id="conflict-time"
|
||||
type="time"
|
||||
<TimePicker
|
||||
value={conflictTime}
|
||||
onChange={(event) => setConflictTime(event.target.value)}
|
||||
className="mb-2"
|
||||
onChange={setConflictTime}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
@ -555,10 +710,10 @@ const TimeClockPage = () => {
|
||||
)}
|
||||
|
||||
{/* Time tracking section */}
|
||||
<div className={isAdmin ? 'admin-time-layout' : undefined}>
|
||||
<div className="w-full">
|
||||
{isAdmin && (
|
||||
<Card className="mb-4">
|
||||
<CardHeader>
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle>
|
||||
{editingEntry ? 'Modifier le pointage' : 'Ajouter un pointage manuel'}
|
||||
</CardTitle>
|
||||
@ -566,75 +721,69 @@ const TimeClockPage = () => {
|
||||
<CardContent>
|
||||
{editingEntry ? (
|
||||
<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>
|
||||
<Label htmlFor="edit-start-time">Heure d'entrée :</Label>
|
||||
<Input
|
||||
id="edit-start-time"
|
||||
type="time"
|
||||
<TimePicker
|
||||
value={editForm.start_time}
|
||||
onChange={(e) => setEditForm({ ...editForm, start_time: e.target.value })}
|
||||
onChange={(value) =>
|
||||
setEditForm({ ...editForm, start_time: value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="edit-end-time">Heure de sortie :</Label>
|
||||
<Input
|
||||
id="edit-end-time"
|
||||
type="time"
|
||||
<TimePicker
|
||||
value={editForm.end_time}
|
||||
onChange={(e) => setEditForm({ ...editForm, end_time: e.target.value })}
|
||||
onChange={(value) =>
|
||||
setEditForm({ ...editForm, end_time: value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit">Enregistrer</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => setEditingEntry(null)}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" onClick={() => setEditingEntry(null)}>Annuler</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<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>
|
||||
<Label>Date:</Label>
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={newEntry.date}
|
||||
onChange={(e) => setNewEntry({...newEntry, date: e.target.value})}
|
||||
required
|
||||
onChange={(value) =>
|
||||
setNewEntry({ ...newEntry, date: value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Date de sortie:</Label>
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={newEntry.end_date}
|
||||
min={newEntry.date}
|
||||
onChange={(e) => setNewEntry({...newEntry, end_date: e.target.value})}
|
||||
required
|
||||
onChange={(value) =>
|
||||
setNewEntry({ ...newEntry, end_date: value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Heure d'entrée:</Label>
|
||||
<Input
|
||||
type="time"
|
||||
<TimePicker
|
||||
value={newEntry.start_time}
|
||||
onChange={(e) => setNewEntry({...newEntry, start_time: e.target.value})}
|
||||
required
|
||||
onChange={(value) =>
|
||||
setNewEntry({ ...newEntry, start_time: value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Heure de sortie:</Label>
|
||||
<Input
|
||||
type="time"
|
||||
<TimePicker
|
||||
value={newEntry.end_time}
|
||||
onChange={(e) => setNewEntry({...newEntry, end_time: e.target.value})}
|
||||
required
|
||||
onChange={(value) =>
|
||||
setNewEntry({ ...newEntry, end_time: value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -645,73 +794,102 @@ const TimeClockPage = () => {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Time entries list */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Historique des pointages</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<p>Chargement des pointages...</p>
|
||||
) : timeEntries.length === 0 ? (
|
||||
<p>Aucun pointage enregistré</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{isAdmin && <TableHead>Utilisateur</TableHead>}
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Entrée</TableHead>
|
||||
<TableHead>Sortie</TableHead>
|
||||
<TableHead>Durée</TableHead>
|
||||
{isAdmin && <TableHead>Actions</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{getDisplayTimeEntries().map((entry) => (
|
||||
<TableRow key={entry.id}>
|
||||
{isAdmin && (
|
||||
<TableCell>{entry.user}</TableCell>
|
||||
)}
|
||||
<TableCell>{formatDate(entry.date)}</TableCell>
|
||||
<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>
|
||||
)}
|
||||
<div className="w-full">
|
||||
{/* Pointage uniquement disponible sur mobile */}
|
||||
<div className="flex flex-col items-center justify-center py-6 md:hidden">
|
||||
<Dialog
|
||||
open={isScannerDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
setIsScannerDialogOpen(true);
|
||||
} else {
|
||||
closeScanner();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button onClick={startScanner} variant="default">
|
||||
{isAdmin ? 'Pointer avec un QR code' : '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>
|
||||
</div>
|
||||
|
||||
{/* Sur ordinateur, le pointage est volontairement indisponible */}
|
||||
{!isAdmin &&
|
||||
<div className="hidden py-6 text-center md:block">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Le pointage est indisponible sur ordinateur.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<Card>
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle>Historique des pointages</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<p>Chargement des pointages...</p>
|
||||
) : timeEntries.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>
|
||||
{isAdmin && <TableHead>Actions</TableHead>}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{getDisplayTimeEntries().map((entry) => (
|
||||
<TableRow key={entry.id}>
|
||||
{isAdmin && <TableCell>{entry.first_name} {entry.last_name}</TableCell>}
|
||||
<TableCell>{formatDate(entry.date)}</TableCell>
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@ -189,8 +189,8 @@ const WeeklySummaryPage = ({ userId }) => {
|
||||
).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const getUserColor = (username) => {
|
||||
const hash = [...username].reduce(
|
||||
const getUserColor = (first_name) => {
|
||||
const hash = [...first_name].reduce(
|
||||
(total, character) => total + character.charCodeAt(0),
|
||||
0,
|
||||
);
|
||||
@ -256,15 +256,15 @@ const WeeklySummaryPage = ({ userId }) => {
|
||||
start,
|
||||
end,
|
||||
users: activeAttendances
|
||||
.map(({ attendance }) => attendance.username)
|
||||
.map(({ attendance }) => attendance.first_name)
|
||||
.filter(Boolean)
|
||||
.filter(
|
||||
(username, usernameIndex, names) =>
|
||||
names.indexOf(username) === usernameIndex,
|
||||
(first_name, first_nameIndex, names) =>
|
||||
names.indexOf(first_name) === first_nameIndex,
|
||||
)
|
||||
.map((username) => ({
|
||||
username,
|
||||
color: getUserColor(username),
|
||||
.map((first_name) => ({
|
||||
first_name,
|
||||
color: getUserColor(first_name),
|
||||
})),
|
||||
};
|
||||
})
|
||||
@ -283,22 +283,22 @@ const WeeklySummaryPage = ({ userId }) => {
|
||||
getTimeValue(attendance, 'end_time'),
|
||||
);
|
||||
|
||||
const username = attendance.username;
|
||||
const first_name = attendance.first_name;
|
||||
|
||||
if (
|
||||
start === null ||
|
||||
end === null ||
|
||||
end <= start ||
|
||||
!username
|
||||
!first_name
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentColumn = columns.get(username);
|
||||
const currentColumn = columns.get(first_name);
|
||||
|
||||
columns.set(username, {
|
||||
username,
|
||||
color: getUserColor(username),
|
||||
columns.set(first_name, {
|
||||
first_name,
|
||||
color: getUserColor(first_name),
|
||||
start: currentColumn
|
||||
? Math.min(currentColumn.start, start)
|
||||
: start,
|
||||
@ -496,16 +496,16 @@ const WeeklySummaryPage = ({ userId }) => {
|
||||
const legendUsers = [
|
||||
...new Set(
|
||||
attendances
|
||||
.map((attendance) => attendance.username)
|
||||
.map((attendance) => attendance.first_name)
|
||||
.filter(Boolean),
|
||||
),
|
||||
]
|
||||
.sort((first, second) =>
|
||||
first.localeCompare(second, 'fr'),
|
||||
)
|
||||
.map((username) => ({
|
||||
username,
|
||||
color: getUserColor(username),
|
||||
.map((first_name) => ({
|
||||
first_name,
|
||||
color: getUserColor(first_name),
|
||||
}));
|
||||
|
||||
const displayDay = isMobile
|
||||
@ -627,9 +627,9 @@ const WeeklySummaryPage = ({ userId }) => {
|
||||
</span>
|
||||
|
||||
{legendUsers.map(
|
||||
({ username, color }) => (
|
||||
({ first_name, color }) => (
|
||||
<div
|
||||
key={username}
|
||||
key={first_name}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<div
|
||||
@ -640,7 +640,7 @@ const WeeklySummaryPage = ({ userId }) => {
|
||||
/>
|
||||
|
||||
<span className="text-sm">
|
||||
{username}
|
||||
{first_name}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
@ -715,7 +715,7 @@ const WeeklySummaryPage = ({ userId }) => {
|
||||
({
|
||||
start,
|
||||
end,
|
||||
username,
|
||||
first_name,
|
||||
color,
|
||||
}, columnIndex) => {
|
||||
const style =
|
||||
@ -731,7 +731,7 @@ const WeeklySummaryPage = ({ userId }) => {
|
||||
<div
|
||||
key={`${formatDateKey(
|
||||
displayDay,
|
||||
)}-${username}`}
|
||||
)}-${first_name}`}
|
||||
className="absolute rounded-sm"
|
||||
style={{
|
||||
...style,
|
||||
@ -743,7 +743,7 @@ const WeeklySummaryPage = ({ userId }) => {
|
||||
backgroundColor:
|
||||
color,
|
||||
}}
|
||||
title={`${username} : ${formatSegmentTime(
|
||||
title={`${first_name} : ${formatSegmentTime(
|
||||
start,
|
||||
)} - ${formatSegmentTime(
|
||||
end,
|
||||
@ -775,8 +775,8 @@ const WeeklySummaryPage = ({ userId }) => {
|
||||
users
|
||||
.map(
|
||||
({
|
||||
username,
|
||||
}) => username,
|
||||
first_name,
|
||||
}) => first_name,
|
||||
)
|
||||
.join(', ');
|
||||
|
||||
@ -890,7 +890,7 @@ const WeeklySummaryPage = ({ userId }) => {
|
||||
({
|
||||
start,
|
||||
end,
|
||||
username,
|
||||
first_name,
|
||||
color,
|
||||
}) => {
|
||||
const style =
|
||||
@ -906,14 +906,14 @@ const WeeklySummaryPage = ({ userId }) => {
|
||||
<div
|
||||
key={`${formatDateKey(
|
||||
day,
|
||||
)}-${username}`}
|
||||
)}-${first_name}`}
|
||||
className="absolute left-2 right-2 rounded-md border"
|
||||
style={{
|
||||
...style,
|
||||
backgroundColor: `${color}22`,
|
||||
borderColor: color,
|
||||
}}
|
||||
title={`${username} : ${formatSegmentTime(
|
||||
title={`${first_name} : ${formatSegmentTime(
|
||||
start,
|
||||
)} - ${formatSegmentTime(
|
||||
end,
|
||||
@ -934,7 +934,7 @@ const WeeklySummaryPage = ({ userId }) => {
|
||||
color,
|
||||
}}
|
||||
>
|
||||
{username}
|
||||
{first_name}
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground">
|
||||
@ -972,9 +972,9 @@ const WeeklySummaryPage = ({ userId }) => {
|
||||
users
|
||||
.map(
|
||||
({
|
||||
username,
|
||||
first_name,
|
||||
}) =>
|
||||
username,
|
||||
first_name,
|
||||
)
|
||||
.join(', ');
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user