444 lines
17 KiB
TypeScript
444 lines
17 KiB
TypeScript
import React, { useState, useRef, useEffect } from 'react';
|
|
import { SchoolData, Attendance, Student } from '../types';
|
|
import { dbService } from '../services/dbService';
|
|
import { useDialog } from '../DialogContext';
|
|
import { Camera, CheckCircle, XCircle, User, SwitchCamera, Loader2, Search, RefreshCw } from 'lucide-react';
|
|
import * as faceapi from '@vladmandic/face-api';
|
|
|
|
interface AttendanceCaptureProps {
|
|
data: SchoolData;
|
|
updateData: (newData: Partial<SchoolData>) => void;
|
|
}
|
|
|
|
const AttendanceCapture: React.FC<AttendanceCaptureProps> = ({ data, updateData }) => {
|
|
const { showAlert } = useDialog();
|
|
const [cameraActive, setCameraActive] = useState(false);
|
|
const [facingMode, setFacingMode] = useState<'user' | 'environment'>('user');
|
|
const [capturedImage, setCapturedImage] = useState<string | null>(null);
|
|
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
|
const [isProcessing, setIsProcessing] = useState(false);
|
|
const [isClosing, setIsClosing] = useState(false);
|
|
const [modelsLoaded, setModelsLoaded] = useState(false);
|
|
|
|
// Auto-detected state
|
|
const [detectedStudentId, setDetectedStudentId] = useState<string | null>(null);
|
|
const [detectedClassId, setDetectedClassId] = useState<string | null>(null);
|
|
|
|
const closeModal = () => {
|
|
setIsClosing(true);
|
|
setTimeout(() => {
|
|
setCapturedImage(null);
|
|
setShowConfirmModal(false);
|
|
setDetectedStudentId(null);
|
|
setDetectedClassId(null);
|
|
setIsProcessing(false);
|
|
setIsClosing(false);
|
|
stopCamera();
|
|
}, 400);
|
|
};
|
|
|
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
const streamRef = useRef<MediaStream | null>(null);
|
|
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
|
|
|
// Load Models
|
|
useEffect(() => {
|
|
const loadModels = async () => {
|
|
const MODEL_URL = 'https://cdn.jsdelivr.net/npm/@vladmandic/face-api/model/';
|
|
try {
|
|
await Promise.all([
|
|
faceapi.nets.tinyFaceDetector.loadFromUri(MODEL_URL),
|
|
faceapi.nets.faceLandmark68Net.loadFromUri(MODEL_URL),
|
|
faceapi.nets.faceRecognitionNet.loadFromUri(MODEL_URL),
|
|
faceapi.nets.ssdMobilenetv1.loadFromUri(MODEL_URL),
|
|
]);
|
|
setModelsLoaded(true);
|
|
} catch (err) {
|
|
console.error("Error loading face-api models", err);
|
|
showAlert('Erro', "Erro ao carregar modelos de reconhecimento facial. Verifique sua conexão.", 'error');
|
|
}
|
|
};
|
|
loadModels();
|
|
}, []);
|
|
|
|
// Start Camera
|
|
const startCamera = async () => {
|
|
try {
|
|
// Stop any existing stream first
|
|
if (streamRef.current) {
|
|
streamRef.current.getTracks().forEach(track => track.stop());
|
|
}
|
|
if (videoRef.current && videoRef.current.srcObject) {
|
|
const oldStream = videoRef.current.srcObject as MediaStream;
|
|
oldStream.getTracks().forEach(track => track.stop());
|
|
videoRef.current.srcObject = null;
|
|
}
|
|
|
|
const stream = await navigator.mediaDevices.getUserMedia({
|
|
video: { facingMode: facingMode }
|
|
});
|
|
|
|
streamRef.current = stream;
|
|
|
|
if (videoRef.current) {
|
|
videoRef.current.srcObject = stream;
|
|
try {
|
|
await videoRef.current.play();
|
|
} catch (e) {
|
|
console.error("Error playing video", e);
|
|
}
|
|
}
|
|
setCameraActive(true);
|
|
setIsProcessing(false);
|
|
} catch (err) {
|
|
console.error("Error accessing camera:", err);
|
|
showAlert('Erro', "Erro ao acessar a câmera. Verifique as permissões.", 'error');
|
|
}
|
|
};
|
|
|
|
// Attach stream to video when active
|
|
useEffect(() => {
|
|
if (cameraActive && videoRef.current && streamRef.current) {
|
|
videoRef.current.srcObject = streamRef.current;
|
|
videoRef.current.play().catch(e => console.error("Error playing video", e));
|
|
}
|
|
}, [cameraActive]);
|
|
|
|
// Stop Camera on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
stopCamera();
|
|
};
|
|
}, []);
|
|
|
|
const stopCamera = () => {
|
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
|
if (streamRef.current) {
|
|
streamRef.current.getTracks().forEach(track => track.stop());
|
|
streamRef.current = null;
|
|
}
|
|
setCameraActive(false);
|
|
};
|
|
|
|
const switchCamera = () => {
|
|
setFacingMode(prev => prev === 'user' ? 'environment' : 'user');
|
|
};
|
|
|
|
// Restart camera when facing mode changes
|
|
useEffect(() => {
|
|
if (cameraActive) {
|
|
startCamera();
|
|
}
|
|
}, [facingMode]);
|
|
|
|
// Face Detection Loop
|
|
useEffect(() => {
|
|
if (cameraActive && modelsLoaded && videoRef.current) {
|
|
const detectFace = async () => {
|
|
if (!videoRef.current || videoRef.current.paused || videoRef.current.ended || isProcessing || showConfirmModal) return;
|
|
|
|
try {
|
|
const detections = await faceapi.detectAllFaces(videoRef.current, new faceapi.TinyFaceDetectorOptions())
|
|
.withFaceLandmarks()
|
|
.withFaceDescriptors();
|
|
|
|
if (detections.length > 0) {
|
|
// Find best match
|
|
const bestMatch = findBestMatch(detections[0].descriptor);
|
|
|
|
if (bestMatch) {
|
|
// Found a student!
|
|
setIsProcessing(true);
|
|
capturePhoto(bestMatch.studentId, bestMatch.classId);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error("Detection error", e);
|
|
}
|
|
};
|
|
|
|
intervalRef.current = setInterval(detectFace, 1000); // Check every 1s
|
|
}
|
|
|
|
return () => {
|
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
|
};
|
|
}, [cameraActive, modelsLoaded, isProcessing, showConfirmModal, data.students]);
|
|
|
|
const findBestMatch = (descriptor: Float32Array) => {
|
|
let bestDistance = 0.6; // Threshold
|
|
let bestStudentId = null;
|
|
let bestClassId = null;
|
|
|
|
// Iterate through all active students who have a face descriptor
|
|
for (const student of data.students) {
|
|
if (student.status !== 'active' || !student.faceDescriptor) continue;
|
|
|
|
const studentDescriptor = new Float32Array(student.faceDescriptor);
|
|
const distance = faceapi.euclideanDistance(descriptor, studentDescriptor);
|
|
|
|
if (distance < bestDistance) {
|
|
bestDistance = distance;
|
|
bestStudentId = student.id;
|
|
bestClassId = student.classId;
|
|
}
|
|
}
|
|
|
|
if (bestStudentId && bestClassId) {
|
|
return { studentId: bestStudentId, classId: bestClassId };
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const capturePhoto = (studentId: string, classId: string) => {
|
|
if (videoRef.current && canvasRef.current) {
|
|
const video = videoRef.current;
|
|
const canvas = canvasRef.current;
|
|
const context = canvas.getContext('2d');
|
|
|
|
if (context) {
|
|
canvas.width = video.videoWidth;
|
|
canvas.height = video.videoHeight;
|
|
context.drawImage(video, 0, 0, canvas.width, canvas.height);
|
|
const imageData = canvas.toDataURL('image/jpeg');
|
|
|
|
setCapturedImage(imageData);
|
|
setDetectedStudentId(studentId);
|
|
setDetectedClassId(classId);
|
|
setShowConfirmModal(true);
|
|
}
|
|
}
|
|
};
|
|
|
|
const confirmPresence = () => {
|
|
if (!detectedStudentId || !detectedClassId || !capturedImage) return;
|
|
|
|
// Check if already present for THIS class within a 1-hour window
|
|
// (Allows multiple presences per day if lessons are at different times)
|
|
const now = new Date();
|
|
const alreadyPresent = data.attendance.some(a => {
|
|
if (a.studentId !== detectedStudentId || a.classId !== detectedClassId) return false;
|
|
const attDate = new Date(a.date);
|
|
const isSameDay = attDate.toDateString() === now.toDateString();
|
|
const diffMs = Math.abs(now.getTime() - attDate.getTime());
|
|
return isSameDay && diffMs < (5 * 60 * 1000); // Intervalo de 5 minutos
|
|
});
|
|
|
|
if (alreadyPresent) {
|
|
showAlert('Atenção', "Aluno já marcou presença hoje!", 'warning');
|
|
cancelCapture();
|
|
return;
|
|
}
|
|
|
|
// Encontrar a aula ativa para esta turma/aluno no momento exato da captura (Sem tolerância de 30 min)
|
|
const nowLocal = new Date();
|
|
const activeLesson = (data.lessons || []).find(l => {
|
|
if (l.classId !== detectedClassId || l.status === 'cancelled') return false;
|
|
const lessonDate = l.date; // YYYY-MM-DD
|
|
const startStr = `${lessonDate}T${l.startTime || '00:00'}:00`;
|
|
const endStr = `${lessonDate}T${l.endTime || '23:59'}:00`;
|
|
const lessonStart = new Date(startStr);
|
|
const lessonEnd = new Date(endStr);
|
|
|
|
// REGRA ESTREITA: Apenas do início ao fim da aula
|
|
return nowLocal >= lessonStart && nowLocal <= lessonEnd;
|
|
});
|
|
|
|
// BLOQUEIO: Se não houver aula em andamento, impede o registro
|
|
if (!activeLesson) {
|
|
showAlert('Atenção', "Nenhuma aula em andamento para esta turma no momento. O registro biométrico só é permitido durante o horário oficial da aula.", 'warning');
|
|
cancelCapture();
|
|
return;
|
|
}
|
|
|
|
// Gerar string de data local forçando o fuso horário de Brasília (America/Sao_Paulo)
|
|
// Isso evita que dispositivos em UTC (como o servidor Windows) salvem horários +3 horas
|
|
const brtString = new Date().toLocaleString('en-US', { timeZone: 'America/Sao_Paulo' });
|
|
const brtDate = new Date(brtString);
|
|
|
|
const localDateStr = brtDate.getFullYear() + '-' +
|
|
String(brtDate.getMonth() + 1).padStart(2, '0') + '-' +
|
|
String(brtDate.getDate()).padStart(2, '0') + 'T' +
|
|
String(brtDate.getHours()).padStart(2, '0') + ':' +
|
|
String(brtDate.getMinutes()).padStart(2, '0') + ':' +
|
|
String(brtDate.getSeconds()).padStart(2, '0');
|
|
|
|
// Limpar qualquer falta auto-gerada para o mesmo aluno nesta mesma aula/dia
|
|
const filteredAttendance = (data.attendance || []).filter(a => {
|
|
const isAutoAbsence = a.studentId === detectedStudentId &&
|
|
a.type === 'absence' &&
|
|
(a.autoGenerated || (a.id && a.id.startsWith('auto-abs-'))) &&
|
|
(a.lessonId === activeLesson.id || (a.date && a.date.startsWith(activeLesson.date)));
|
|
return !isAutoAbsence;
|
|
});
|
|
|
|
const newAttendance: Attendance = {
|
|
id: crypto.randomUUID(),
|
|
studentId: detectedStudentId,
|
|
classId: detectedClassId,
|
|
lessonId: activeLesson.id,
|
|
date: localDateStr,
|
|
photo: capturedImage,
|
|
type: 'presence',
|
|
verified: true
|
|
};
|
|
|
|
fetch('/api/frequencias', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(newAttendance)
|
|
});
|
|
|
|
const updatedAttendance = [...filteredAttendance, newAttendance];
|
|
updateData({ attendance: updatedAttendance });
|
|
|
|
// Reset de interface
|
|
setCapturedImage(null);
|
|
setShowConfirmModal(false);
|
|
setDetectedStudentId(null);
|
|
setDetectedClassId(null);
|
|
setIsProcessing(false);
|
|
closeModal();
|
|
showAlert('Sucesso', "Presença registrada e sincronizada com o servidor!", 'success');
|
|
};
|
|
|
|
const cancelCapture = () => {
|
|
closeModal();
|
|
};
|
|
|
|
const detectedStudent = data.students.find(s => s.id === detectedStudentId);
|
|
const detectedClass = data.classes.find(c => c.id === detectedClassId);
|
|
|
|
return (
|
|
<div className="max-w-4xl mx-auto space-y-6 animate-in fade-in duration-300 pb-20 px-4">
|
|
<header className="text-center">
|
|
<h2 className="text-2xl md:text-3xl font-extrabold text-slate-900 tracking-tight">Registro de Presença</h2>
|
|
<p className="text-slate-500 text-sm md:text-base font-medium">Posicione o rosto para identificação automática.</p>
|
|
</header>
|
|
|
|
<div className="flex flex-col items-center gap-6">
|
|
{/* Camera View Container */}
|
|
<div className="w-full max-w-md space-y-4">
|
|
<div className="bg-black rounded-2xl overflow-hidden relative aspect-[3/4] shadow-2xl flex flex-col border-4 border-white">
|
|
{cameraActive ? (
|
|
<>
|
|
<video
|
|
ref={videoRef}
|
|
autoPlay
|
|
playsInline
|
|
muted
|
|
className="w-full h-full object-cover flex-1"
|
|
/>
|
|
<canvas ref={canvasRef} className="hidden" />
|
|
|
|
{/* Overlay UI */}
|
|
<div className="absolute inset-0 pointer-events-none border-[3px] border-white/20 m-6 md:m-10 rounded-2xl flex flex-col items-center justify-center">
|
|
<div className="w-40 h-40 md:w-56 md:h-56 border-2 border-dashed border-white/40 rounded-full mb-4 animate-pulse"></div>
|
|
<p className="text-white/90 text-xs md:text-sm font-bold bg-black/50 px-4 py-1.5 rounded-full backdrop-blur-md">
|
|
Aguardando rosto...
|
|
</p>
|
|
</div>
|
|
|
|
{/* Switch Camera Button (Floating) */}
|
|
<button
|
|
onClick={switchCamera}
|
|
className="absolute bottom-4 right-4 p-3 bg-white/20 hover:bg-white/30 text-white rounded-full backdrop-blur-md transition-all active:scale-90"
|
|
title="Alternar Câmera"
|
|
>
|
|
<SwitchCamera size={20} />
|
|
</button>
|
|
</>
|
|
) : (
|
|
<div className="w-full h-full flex flex-col items-center justify-center text-slate-400 p-8 text-center bg-slate-50">
|
|
<div className="w-20 h-20 bg-slate-100 rounded-full flex items-center justify-center mb-4">
|
|
<Camera size={40} className="opacity-20" />
|
|
</div>
|
|
<p className="text-sm font-medium">A câmera está desligada.</p>
|
|
<p className="text-xs mt-1">Clique no botão abaixo para iniciar.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Main Action Button */}
|
|
{!cameraActive ? (
|
|
<button
|
|
onClick={startCamera}
|
|
disabled={!modelsLoaded}
|
|
className="w-full py-5 bg-emerald-600 text-white rounded-2xl font-black text-xl hover:bg-emerald-700 shadow-xl shadow-emerald-100 flex items-center justify-center gap-3 transition-all hover:scale-[1.02] active:scale-[0.98] disabled:opacity-50"
|
|
>
|
|
<CheckCircle size={28} /> Marcar Presença
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={stopCamera}
|
|
className="w-full py-5 bg-red-500 text-white rounded-2xl font-black text-xl hover:bg-red-600 shadow-xl shadow-red-100 flex items-center justify-center gap-3 transition-all hover:scale-[1.02] active:scale-[0.98]"
|
|
>
|
|
<XCircle size={28} /> Cancelar
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* System Status (Minimalist) */}
|
|
{!cameraActive && (
|
|
<div className="flex flex-wrap justify-center gap-4 text-[10px] font-bold uppercase tracking-widest text-slate-400">
|
|
<div className="flex items-center gap-1.5">
|
|
<div className={`w-2 h-2 rounded-full ${modelsLoaded ? 'bg-emerald-500' : 'bg-amber-500 animate-pulse'}`}></div>
|
|
{modelsLoaded ? 'IA Pronta' : 'Carregando IA'}
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
<User size={12} />
|
|
{data.students.filter(s => s.faceDescriptor).length} Faces Cadastradas
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Confirmation Modal */}
|
|
{showConfirmModal && capturedImage && detectedStudent && (
|
|
<div className={`fixed inset-0 bg-transparent z-50 flex items-center justify-center p-4 transition-opacity duration-400 ${isClosing ? 'opacity-0' : 'opacity-100 animate-in fade-in'}`}>
|
|
<div className={`bg-white rounded-3xl w-full max-w-sm overflow-hidden shadow-2xl transition-all duration-400 relative ${isClosing ? 'animate-slide-down-fade-out' : 'animate-slide-up'}`}>
|
|
{/* Blue Top Bar */}
|
|
<div className="bg-indigo-600 h-1.5 w-full absolute top-0 left-0 z-10"></div>
|
|
|
|
<div className="p-8 text-center space-y-6">
|
|
<div className="space-y-1">
|
|
<h3 className="text-2xl font-black text-slate-800">Identificado!</h3>
|
|
<p className="text-slate-500 text-sm font-medium">Confirmar presença para:</p>
|
|
</div>
|
|
|
|
<div className="relative w-48 h-48 mx-auto rounded-full overflow-hidden border-4 border-emerald-500 shadow-2xl">
|
|
<img src={capturedImage} alt="Captured" className="w-full h-full object-cover" />
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<p className="text-xl font-black text-indigo-900">{detectedStudent.name}</p>
|
|
<p className="text-sm font-bold text-indigo-500 bg-indigo-50 inline-block px-3 py-1 rounded-full">{detectedClass?.name}</p>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-3 pt-4">
|
|
<button
|
|
onClick={confirmPresence}
|
|
className="w-full py-4 bg-emerald-500 text-white rounded-2xl font-black text-lg hover:bg-emerald-600 shadow-lg shadow-emerald-200 flex items-center justify-center gap-2 transition-all active:scale-95"
|
|
>
|
|
<CheckCircle size={24} /> Confirmar Agora
|
|
</button>
|
|
<button
|
|
onClick={cancelCapture}
|
|
className="w-full py-3 text-slate-400 font-bold hover:text-red-500 transition-colors"
|
|
>
|
|
Não sou eu / Cancelar
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default AttendanceCapture;
|
|
|
|
|