570 lines
19 KiB
JavaScript
570 lines
19 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 { Input } from '@/components/ui/input';
|
||
import { Label } from '@/components/ui/label';
|
||
import { Button } from '@/components/ui/button';
|
||
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';
|
||
|
||
const TimeClockPage = () => {
|
||
const videoRef = useRef(null);
|
||
const streamRef = useRef(null);
|
||
const scanFrameRef = useRef(null);
|
||
const qrControlsRef = useRef(null);
|
||
const { isAdmin } = useAuth();
|
||
const [isScanning, setIsScanning] = useState(false);
|
||
const [scanMessage, setScanMessage] = useState('');
|
||
const [scanConflict, setScanConflict] = useState(null);
|
||
const [conflictTime, setConflictTime] = useState(new Date().toTimeString().slice(0, 5));
|
||
const [error, setError] = useState('');
|
||
const [currentTime, setCurrentTime] = useState(new Date());
|
||
const [timeEntries, setTimeEntries] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [newEntry, setNewEntry] = useState({
|
||
date: new Date().toISOString().split('T')[0],
|
||
end_date: new Date().toISOString().split('T')[0],
|
||
start_time: '',
|
||
end_time: ''
|
||
});
|
||
const [editingEntry, setEditingEntry] = useState(null);
|
||
const [editForm, setEditForm] = useState({
|
||
start_time: '',
|
||
end_time: ''
|
||
});
|
||
|
||
// Update current time every second
|
||
useEffect(() => {
|
||
const timer = setInterval(() => {
|
||
setCurrentTime(new Date());
|
||
}, 1000);
|
||
|
||
return () => clearInterval(timer);
|
||
}, []);
|
||
|
||
const loadTimeEntries = async () => {
|
||
try {
|
||
const entries = await timeApi.getTimeEntries();
|
||
setTimeEntries(entries);
|
||
} catch (err) {
|
||
setError('Erreur lors du chargement des pointages');
|
||
console.error(err);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
// Load time entries when component mounts
|
||
useEffect(() => {
|
||
loadTimeEntries();
|
||
}, []);
|
||
|
||
const handleAddEntry = async (e) => {
|
||
e.preventDefault();
|
||
if (!newEntry.start_time || !newEntry.end_time) {
|
||
setError('Veuillez remplir tous les champs');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
if (newEntry.date === newEntry.end_date) {
|
||
await timeApi.createTimeEntry({
|
||
date: newEntry.date,
|
||
start_time: newEntry.start_time,
|
||
end_time: newEntry.end_time,
|
||
});
|
||
} else {
|
||
const dates = [];
|
||
const cursor = new Date(`${newEntry.date}T12:00:00`);
|
||
const lastDate = new Date(`${newEntry.end_date}T12:00:00`);
|
||
|
||
while (cursor <= lastDate) {
|
||
dates.push(cursor.toISOString().slice(0, 10));
|
||
cursor.setDate(cursor.getDate() + 1);
|
||
}
|
||
|
||
await Promise.all(dates.map((date, index) => {
|
||
if (index === 0) {
|
||
return timeApi.createTimeEntry({ date, start_time: newEntry.start_time });
|
||
}
|
||
if (index === dates.length - 1) {
|
||
return timeApi.createTimeEntry({ date, end_time: newEntry.end_time });
|
||
}
|
||
return timeApi.createTimeEntry({ date, start_time: '00:00', end_time: '23:59' });
|
||
}));
|
||
}
|
||
setNewEntry({
|
||
date: new Date().toISOString().split('T')[0],
|
||
end_date: new Date().toISOString().split('T')[0],
|
||
start_time: '',
|
||
end_time: ''
|
||
});
|
||
loadTimeEntries(); // Refresh the list
|
||
setError('');
|
||
} catch (err) {
|
||
setError('Erreur lors de l\'ajout du pointage');
|
||
console.error(err);
|
||
}
|
||
};
|
||
|
||
const handleEditEntry = (entry) => {
|
||
setEditingEntry(entry);
|
||
setEditForm({
|
||
start_time: entry.start_time,
|
||
end_time: entry.end_time
|
||
});
|
||
};
|
||
|
||
const handleUpdateEntry = async (e) => {
|
||
e.preventDefault();
|
||
if (!editForm.start_time && !editForm.end_time) {
|
||
setError('Veuillez remplir tous les champs');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await timeApi.updateTimeEntry(editingEntry.id, editForm);
|
||
setEditingEntry(null);
|
||
setEditForm({ start_time: '', end_time: '' });
|
||
loadTimeEntries(); // Refresh the list
|
||
setError('');
|
||
} catch (err) {
|
||
setError('Erreur lors de la mise à jour du pointage');
|
||
console.error(err);
|
||
}
|
||
};
|
||
|
||
const handleDeleteEntry = async (id) => {
|
||
// Remplacer window.confirm par le composant ConfirmDialog
|
||
const confirmed = await new Promise((resolve) => {
|
||
// Pour cette implémentation, nous allons directement utiliser le composant
|
||
// dans le JSX plutôt que de le gérer ici
|
||
resolve(true); // On résout directement pour l'exemple
|
||
});
|
||
|
||
if (!confirmed) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await timeApi.deleteTimeEntry(id);
|
||
loadTimeEntries(); // Refresh the list
|
||
setError('');
|
||
} catch (err) {
|
||
setError('Erreur lors de la suppression du pointage');
|
||
console.error(err);
|
||
}
|
||
};
|
||
|
||
const stopScanner = () => {
|
||
const controls = qrControlsRef.current;
|
||
qrControlsRef.current = null;
|
||
|
||
try {
|
||
controls?.stop();
|
||
} catch (stopError) {
|
||
console.warn('Arrêt du lecteur QR déjà effectué.', stopError);
|
||
}
|
||
|
||
if (scanFrameRef.current) {
|
||
cancelAnimationFrame(scanFrameRef.current);
|
||
scanFrameRef.current = null;
|
||
}
|
||
streamRef.current?.getTracks().forEach((track) => track.stop());
|
||
streamRef.current = null;
|
||
if (videoRef.current) {
|
||
videoRef.current.srcObject = null;
|
||
}
|
||
setIsScanning(false);
|
||
};
|
||
|
||
const handleQrResult = async (code) => {
|
||
setScanConflict(null);
|
||
stopScanner();
|
||
|
||
try {
|
||
const response = await timeApi.scanTimeEntry(code);
|
||
setScanMessage(response.message);
|
||
await loadTimeEntries();
|
||
} catch (scanError) {
|
||
if (scanError.code === 409 && scanError.conflict) {
|
||
setScanConflict({ code, type: scanError.conflict, message: scanError.message });
|
||
} else {
|
||
setError(scanError.message);
|
||
}
|
||
}
|
||
};
|
||
|
||
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();
|
||
} catch (resolutionError) {
|
||
setError(resolutionError.message);
|
||
}
|
||
};
|
||
|
||
useEffect(() => () => stopScanner(), []);
|
||
|
||
useEffect(() => {
|
||
if (!isScanning || !videoRef.current) return undefined;
|
||
|
||
let cancelled = false;
|
||
const reader = new BrowserQRCodeReader();
|
||
|
||
const startReader = async () => {
|
||
try {
|
||
const controls = await reader.decodeFromConstraints(
|
||
{ video: { facingMode: { ideal: 'environment' } }, audio: false },
|
||
videoRef.current,
|
||
(result) => {
|
||
if (result && !cancelled) {
|
||
handleQrResult(result.getText());
|
||
}
|
||
},
|
||
);
|
||
|
||
if (cancelled) {
|
||
try {
|
||
controls.stop();
|
||
} catch (stopError) {
|
||
console.warn('Arrêt du lecteur QR déjà effectué.', stopError);
|
||
}
|
||
} else {
|
||
qrControlsRef.current = controls;
|
||
}
|
||
} catch (scannerError) {
|
||
if (!cancelled) {
|
||
setError('Impossible d’accéder à la caméra. Vérifiez les permissions et utilisez HTTPS sur le réseau.');
|
||
console.error(scannerError);
|
||
stopScanner();
|
||
}
|
||
}
|
||
};
|
||
|
||
startReader();
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
try {
|
||
reader.reset();
|
||
} catch (resetError) {
|
||
console.warn('Réinitialisation du lecteur QR ignorée.', resetError);
|
||
}
|
||
};
|
||
}, [isScanning]);
|
||
|
||
const startScanner = async () => {
|
||
setError('');
|
||
setScanMessage('');
|
||
setScanConflict(null);
|
||
|
||
if (!window.isSecureContext && window.location.hostname !== 'localhost' && window.location.hostname !== '127.0.0.1') {
|
||
setError('La caméra nécessite une connexion HTTPS sur cette adresse réseau.');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
if (!navigator.mediaDevices?.getUserMedia) {
|
||
setError('Ce navigateur ne permet pas l’accès à la caméra.');
|
||
return;
|
||
}
|
||
|
||
setIsScanning(true);
|
||
} catch (scannerError) {
|
||
setError('Impossible d’accéder à la caméra. Vérifiez les permissions et utilisez HTTPS sur le réseau.');
|
||
console.error(scannerError);
|
||
stopScanner();
|
||
}
|
||
};
|
||
|
||
const formatTime = (timeString) => {
|
||
if (!timeString) return '00h00';
|
||
const [hours, minutes] = timeString.split(':');
|
||
if (hours === '24') return '23h59';
|
||
return `${String(hours).padStart(2, '0')}h${String(minutes).padStart(2, '0')}`;
|
||
};
|
||
|
||
const getDisplayTimeEntries = () => {
|
||
const toMinutes = (time) => {
|
||
const [hours, minutes] = time.split(':').map(Number);
|
||
return hours * 60 + minutes;
|
||
};
|
||
|
||
return timeEntries.map((entry) => {
|
||
if (entry.start_time && !entry.end_time) {
|
||
return {
|
||
...entry,
|
||
end_time: '24:00:00',
|
||
duration: (1440 - toMinutes(entry.start_time)) / 60,
|
||
};
|
||
}
|
||
if (!entry.start_time && entry.end_time) {
|
||
return {
|
||
...entry,
|
||
start_time: '00:00:00',
|
||
duration: toMinutes(entry.end_time) / 60,
|
||
};
|
||
}
|
||
return entry;
|
||
});
|
||
};
|
||
|
||
const formatDate = (dateString) => {
|
||
const date = new Date(dateString);
|
||
return date.toLocaleDateString('fr-FR');
|
||
};
|
||
|
||
const formatDuration = (duration) => {
|
||
const totalMinutes = Math.round(Number(duration || 0) * 60);
|
||
const hours = String(Math.floor(totalMinutes / 60)).padStart(2, '0');
|
||
const minutes = String(totalMinutes % 60).padStart(2, '0');
|
||
|
||
return `${hours}h${minutes}`;
|
||
};
|
||
|
||
return (
|
||
<Card className="flex h-full w-full">
|
||
<CardHeader>
|
||
<CardTitle>Pointage</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{error && (
|
||
<Alert variant="destructive" className="mb-4">
|
||
<AlertDescription>{error}</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
|
||
{isScanning ? (
|
||
<div className="scanner-panel">
|
||
<video ref={videoRef} className="scanner-video" playsInline muted />
|
||
<div className="scanner-frame" aria-hidden="true" />
|
||
<Button onClick={stopScanner} variant="secondary" className="mt-2">
|
||
Annuler
|
||
</Button>
|
||
</div>
|
||
) : (
|
||
<Button
|
||
onClick={startScanner}
|
||
variant="default"
|
||
className="mb-4"
|
||
>
|
||
{isAdmin ? "Pointer avec un QR code" : "Pointer"}
|
||
</Button>
|
||
)}
|
||
|
||
{scanMessage && (
|
||
<Alert variant="success" className="mb-4">
|
||
<AlertDescription>{scanMessage}</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
|
||
{scanConflict && (
|
||
<Card className="mb-4">
|
||
<CardHeader>
|
||
<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"
|
||
value={conflictTime}
|
||
onChange={(event) => setConflictTime(event.target.value)}
|
||
className="mb-2"
|
||
/>
|
||
<div className="flex gap-2">
|
||
<Button
|
||
onClick={() => resolveScanConflict(scanConflict.type === 'open_entry' ? 'cancel_entry' : 'cancel_exit')}
|
||
variant="destructive"
|
||
>
|
||
Annuler le pointage
|
||
</Button>
|
||
<Button
|
||
onClick={() => resolveScanConflict(scanConflict.type === 'open_entry' ? 'complete_entry' : 'complete_exit')}
|
||
variant="default"
|
||
>
|
||
Renseigner l’heure
|
||
</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* Time tracking section */}
|
||
<div className={isAdmin ? 'admin-time-layout' : undefined}>
|
||
{isAdmin && (
|
||
<Card className="mb-4">
|
||
<CardHeader>
|
||
<CardTitle>
|
||
{editingEntry ? 'Modifier le pointage' : 'Ajouter un pointage manuel'}
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{editingEntry ? (
|
||
<form onSubmit={handleUpdateEntry}>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||
<div>
|
||
<Label htmlFor="edit-start-time">Heure d'entrée :</Label>
|
||
<Input
|
||
id="edit-start-time"
|
||
type="time"
|
||
value={editForm.start_time}
|
||
onChange={(e) => setEditForm({ ...editForm, start_time: e.target.value })}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label htmlFor="edit-end-time">Heure de sortie :</Label>
|
||
<Input
|
||
id="edit-end-time"
|
||
type="time"
|
||
value={editForm.end_time}
|
||
onChange={(e) => setEditForm({ ...editForm, end_time: e.target.value })}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<Button type="submit">Enregistrer</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>
|
||
<Label>Date:</Label>
|
||
<Input
|
||
type="date"
|
||
value={newEntry.date}
|
||
onChange={(e) => setNewEntry({...newEntry, date: e.target.value})}
|
||
required
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label>Date de sortie:</Label>
|
||
<Input
|
||
type="date"
|
||
value={newEntry.end_date}
|
||
min={newEntry.date}
|
||
onChange={(e) => setNewEntry({...newEntry, end_date: e.target.value})}
|
||
required
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label>Heure d'entrée:</Label>
|
||
<Input
|
||
type="time"
|
||
value={newEntry.start_time}
|
||
onChange={(e) => setNewEntry({...newEntry, start_time: e.target.value})}
|
||
required
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label>Heure de sortie:</Label>
|
||
<Input
|
||
type="time"
|
||
value={newEntry.end_time}
|
||
onChange={(e) => setNewEntry({...newEntry, end_time: e.target.value})}
|
||
required
|
||
/>
|
||
</div>
|
||
</div>
|
||
<Button type="submit">Ajouter le pointage</Button>
|
||
</form>
|
||
)}
|
||
</CardContent>
|
||
</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>
|
||
)}
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
};
|
||
|
||
export default TimeClockPage; |