2026-09-18 03:19:06 +02:00

570 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import { useEffect, useRef, useState } from 'react';
import { BrowserQRCodeReader } from '@zxing/browser';
import { timeApi } from '../api/time';
import { useAuth } from '../context/useAuth';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { 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;