fix: biometric attendance timezone shift and mandatory lesson binding

This commit is contained in:
Sidney 2026-05-11 21:43:31 -03:00
parent 1552e5cb19
commit a1b5075e41
4 changed files with 146 additions and 100 deletions

View File

@ -19,7 +19,7 @@ const AttendanceCapture: React.FC<AttendanceCaptureProps> = ({ data, updateData
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
const [isClosing, setIsClosing] = useState(false); const [isClosing, setIsClosing] = useState(false);
const [modelsLoaded, setModelsLoaded] = useState(false); const [modelsLoaded, setModelsLoaded] = useState(false);
// Auto-detected state // Auto-detected state
const [detectedStudentId, setDetectedStudentId] = useState<string | null>(null); const [detectedStudentId, setDetectedStudentId] = useState<string | null>(null);
const [detectedClassId, setDetectedClassId] = useState<string | null>(null); const [detectedClassId, setDetectedClassId] = useState<string | null>(null);
@ -75,18 +75,18 @@ const AttendanceCapture: React.FC<AttendanceCaptureProps> = ({ data, updateData
videoRef.current.srcObject = null; videoRef.current.srcObject = null;
} }
const stream = await navigator.mediaDevices.getUserMedia({ const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: facingMode } video: { facingMode: facingMode }
}); });
streamRef.current = stream; streamRef.current = stream;
if (videoRef.current) { if (videoRef.current) {
videoRef.current.srcObject = stream; videoRef.current.srcObject = stream;
try { try {
await videoRef.current.play(); await videoRef.current.play();
} catch (e) { } catch (e) {
console.error("Error playing video", e); console.error("Error playing video", e);
} }
} }
setCameraActive(true); setCameraActive(true);
@ -146,7 +146,7 @@ const AttendanceCapture: React.FC<AttendanceCaptureProps> = ({ data, updateData
if (detections.length > 0) { if (detections.length > 0) {
// Find best match // Find best match
const bestMatch = findBestMatch(detections[0].descriptor); const bestMatch = findBestMatch(detections[0].descriptor);
if (bestMatch) { if (bestMatch) {
// Found a student! // Found a student!
setIsProcessing(true); setIsProcessing(true);
@ -174,7 +174,7 @@ const AttendanceCapture: React.FC<AttendanceCaptureProps> = ({ data, updateData
// Iterate through all active students who have a face descriptor // Iterate through all active students who have a face descriptor
for (const student of data.students) { for (const student of data.students) {
if (student.status !== 'active' || !student.faceDescriptor) continue; if (student.status !== 'active' || !student.faceDescriptor) continue;
const studentDescriptor = new Float32Array(student.faceDescriptor); const studentDescriptor = new Float32Array(student.faceDescriptor);
const distance = faceapi.euclideanDistance(descriptor, studentDescriptor); const distance = faceapi.euclideanDistance(descriptor, studentDescriptor);
@ -202,7 +202,7 @@ const AttendanceCapture: React.FC<AttendanceCaptureProps> = ({ data, updateData
canvas.height = video.videoHeight; canvas.height = video.videoHeight;
context.drawImage(video, 0, 0, canvas.width, canvas.height); context.drawImage(video, 0, 0, canvas.width, canvas.height);
const imageData = canvas.toDataURL('image/jpeg'); const imageData = canvas.toDataURL('image/jpeg');
setCapturedImage(imageData); setCapturedImage(imageData);
setDetectedStudentId(studentId); setDetectedStudentId(studentId);
setDetectedClassId(classId); setDetectedClassId(classId);
@ -231,11 +231,41 @@ const AttendanceCapture: React.FC<AttendanceCaptureProps> = ({ data, updateData
return; return;
} }
// Encontrar a aula ativa para esta turma/aluno no momento da captura
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);
// Janela: 30 min antes do início até o fim da aula
const windowStart = new Date(lessonStart.getTime() - 30 * 60 * 1000);
return nowLocal >= windowStart && nowLocal <= lessonEnd;
});
// REGRA ESTRITA: A presença só pode ser marcada se houver uma aula ativa
if (!activeLesson) {
showAlert('Atenção', "Nenhuma aula ativa detectada para esta turma no momento. A presença só pode ser registrada a partir de 30 minutos antes do início até o término da aula.", 'warning');
cancelCapture();
return;
}
// Gerar string de data local (YYYY-MM-DDTHH:MM:SS) sem fuso UTC para evitar o bug do dia seguinte
const localDateStr = nowLocal.getFullYear() + '-' +
String(nowLocal.getMonth() + 1).padStart(2, '0') + '-' +
String(nowLocal.getDate()).padStart(2, '0') + 'T' +
String(nowLocal.getHours()).padStart(2, '0') + ':' +
String(nowLocal.getMinutes()).padStart(2, '0') + ':' +
String(nowLocal.getSeconds()).padStart(2, '0');
const newAttendance: Attendance = { const newAttendance: Attendance = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
studentId: detectedStudentId, studentId: detectedStudentId,
classId: detectedClassId, classId: detectedClassId,
date: new Date().toISOString(), lessonId: activeLesson.id, // Vínculo obrigatório agora
date: localDateStr,
photo: capturedImage, photo: capturedImage,
type: 'presence', type: 'presence',
verified: true verified: true
@ -275,15 +305,15 @@ const AttendanceCapture: React.FC<AttendanceCaptureProps> = ({ data, updateData
<div className="bg-black rounded-2xl overflow-hidden relative aspect-[3/4] shadow-2xl flex flex-col border-4 border-white"> <div className="bg-black rounded-2xl overflow-hidden relative aspect-[3/4] shadow-2xl flex flex-col border-4 border-white">
{cameraActive ? ( {cameraActive ? (
<> <>
<video <video
ref={videoRef} ref={videoRef}
autoPlay autoPlay
playsInline playsInline
muted muted
className="w-full h-full object-cover flex-1" className="w-full h-full object-cover flex-1"
/> />
<canvas ref={canvasRef} className="hidden" /> <canvas ref={canvasRef} className="hidden" />
{/* Overlay UI */} {/* 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="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> <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>
@ -293,7 +323,7 @@ const AttendanceCapture: React.FC<AttendanceCaptureProps> = ({ data, updateData
</div> </div>
{/* Switch Camera Button (Floating) */} {/* Switch Camera Button (Floating) */}
<button <button
onClick={switchCamera} 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" 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" title="Alternar Câmera"
@ -314,7 +344,7 @@ const AttendanceCapture: React.FC<AttendanceCaptureProps> = ({ data, updateData
{/* Main Action Button */} {/* Main Action Button */}
{!cameraActive ? ( {!cameraActive ? (
<button <button
onClick={startCamera} onClick={startCamera}
disabled={!modelsLoaded} 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" 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"
@ -322,7 +352,7 @@ const AttendanceCapture: React.FC<AttendanceCaptureProps> = ({ data, updateData
<CheckCircle size={28} /> Marcar Presença <CheckCircle size={28} /> Marcar Presença
</button> </button>
) : ( ) : (
<button <button
onClick={stopCamera} 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]" 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]"
> >
@ -352,13 +382,13 @@ const AttendanceCapture: React.FC<AttendanceCaptureProps> = ({ data, updateData
<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'}`}> <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 */} {/* Blue Top Bar */}
<div className="bg-indigo-600 h-1.5 w-full absolute top-0 left-0 z-10"></div> <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="p-8 text-center space-y-6">
<div className="space-y-1"> <div className="space-y-1">
<h3 className="text-2xl font-black text-slate-800">Identificado!</h3> <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> <p className="text-slate-500 text-sm font-medium">Confirmar presença para:</p>
</div> </div>
<div className="relative w-48 h-48 mx-auto rounded-full overflow-hidden border-4 border-emerald-500 shadow-2xl"> <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" /> <img src={capturedImage} alt="Captured" className="w-full h-full object-cover" />
</div> </div>
@ -369,13 +399,13 @@ const AttendanceCapture: React.FC<AttendanceCaptureProps> = ({ data, updateData
</div> </div>
<div className="flex flex-col gap-3 pt-4"> <div className="flex flex-col gap-3 pt-4">
<button <button
onClick={confirmPresence} 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" 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 <CheckCircle size={24} /> Confirmar Agora
</button> </button>
<button <button
onClick={cancelCapture} onClick={cancelCapture}
className="w-full py-3 text-slate-400 font-bold hover:text-red-500 transition-colors" className="w-full py-3 text-slate-400 font-bold hover:text-red-500 transition-colors"
> >

View File

@ -39,12 +39,18 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
const [showAbsenceModal, setShowAbsenceModal] = useState(false); const [showAbsenceModal, setShowAbsenceModal] = useState(false);
const [isClosing, setIsClosing] = useState(false); const [isClosing, setIsClosing] = useState(false);
const [isClosing2, setIsClosing2] = useState(false); const [isClosing2, setIsClosing2] = useState(false);
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]); // Função para obter data local YYYY-MM-DD sem risco de fuso horário UTC
const getTodayLocal = () => {
const d = new Date();
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
};
const [selectedDate, setSelectedDate] = useState(getTodayLocal());
const [isGeneratingPDF, setIsGeneratingPDF] = useState(false); const [isGeneratingPDF, setIsGeneratingPDF] = useState(false);
const [absenceStudentId, setAbsenceStudentId] = useState(''); const [absenceStudentId, setAbsenceStudentId] = useState('');
const [absenceJustification, setAbsenceJustification] = useState(''); const [absenceJustification, setAbsenceJustification] = useState('');
const [absenceDate, setAbsenceDate] = useState(new Date().toISOString().split('T')[0]); const [absenceDate, setAbsenceDate] = useState(getTodayLocal());
const [absenceLessonId, setAbsenceLessonId] = useState(''); const [absenceLessonId, setAbsenceLessonId] = useState('');
const [viewingAttachment, setViewingAttachment] = useState<string | null>(null); const [viewingAttachment, setViewingAttachment] = useState<string | null>(null);
const [attendanceForAttachment, setAttendanceForAttachment] = useState<Attendance | null>(null); const [attendanceForAttachment, setAttendanceForAttachment] = useState<Attendance | null>(null);
@ -57,36 +63,44 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
if (!url || typeof url !== 'string') return ''; if (!url || typeof url !== 'string') return '';
if (url.startsWith('data:image') || url.startsWith('blob:')) return url; if (url.startsWith('data:image') || url.startsWith('blob:')) return url;
if (url.startsWith('/storage/')) return url; if (url.startsWith('/storage/')) return url;
try { try {
const match = url.match(/^https?:\/\/[^\/]+\/(.+)$/); const match = url.match(/^https?:\/\/[^\/]+\/(.+)$/);
if (match) return `/storage/${match[1]}`; if (match) return `/storage/${match[1]}`;
} catch(e) {} } catch (e) { }
return url; return url;
}; };
const toggleAttendanceStatus = (record: any) => { const toggleAttendanceStatus = (record: any) => {
let updatedAttendance = [...(data.attendance || [])]; let updatedAttendance = [...(data.attendance || [])];
if (record.isVirtual) { if (record.isVirtual) {
// Ação do botão do Admin: criar o registro real a partir do virtual // Ação do botão do Admin: criar o registro real a partir do virtual
const lesson = data.lessons.find(l => l.id === record.id.replace('v-', '')); const lesson = data.lessons.find(l => l.id === record.id.replace('v-', ''));
const newType = (record.type === 'absence' || record.type === 'awaiting') ? 'presence' : 'absence'; const newType = (record.type === 'absence' || record.type === 'awaiting') ? 'presence' : 'absence';
const now = new Date();
const localDate = now.getFullYear() + '-' +
String(now.getMonth() + 1).padStart(2, '0') + '-' +
String(now.getDate()).padStart(2, '0') + 'T' +
String(now.getHours()).padStart(2, '0') + ':' +
String(now.getMinutes()).padStart(2, '0') + ':' +
String(now.getSeconds()).padStart(2, '0');
const newRecord: Attendance = { const newRecord: Attendance = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
studentId: record.studentId, studentId: record.studentId,
classId: record.classId, classId: record.classId,
date: lesson ? `${lesson.date}T${lesson.startTime || '00:00'}:00` : new Date().toISOString(), date: lesson ? `${lesson.date}T${lesson.startTime || '00:00'}:00` : localDate,
verified: true, verified: true,
type: newType, type: newType,
...(lesson ? { lessonId: lesson.id } : {}) // Vinculação rígida para não haver duplicidade ...(lesson ? { lessonId: lesson.id } : {}) // Vinculação rígida para não haver duplicidade
}; };
// Garantir que não duplica se já houver por algum erro // Garantir que não duplica se já houver por algum erro
const existingIdx = updatedAttendance.findIndex(a => const existingIdx = updatedAttendance.findIndex(a =>
a.studentId === record.studentId && a.studentId === record.studentId &&
((a as any).lessonId === lesson?.id || a.date === newRecord.date) ((a as any).lessonId === lesson?.id || a.date === newRecord.date)
); );
@ -98,7 +112,7 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
} else { } else {
// Toggle existing record // Toggle existing record
const newType = record.type === 'absence' ? 'presence' : 'absence'; const newType = record.type === 'absence' ? 'presence' : 'absence';
updatedAttendance = updatedAttendance.map(a => updatedAttendance = updatedAttendance.map(a =>
a.id === record.id ? { ...a, type: newType, justification: undefined, justificationAccepted: undefined } : a a.id === record.id ? { ...a, type: newType, justification: undefined, justificationAccepted: undefined } : a
); );
} }
@ -110,23 +124,23 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
const handleDeleteAttachmentRecord = () => { const handleDeleteAttachmentRecord = () => {
if (!attendanceForAttachment || !attendanceForAttachment.justification) return; if (!attendanceForAttachment || !attendanceForAttachment.justification) return;
try { try {
const parsed = JSON.parse(attendanceForAttachment.justification); const parsed = JSON.parse(attendanceForAttachment.justification);
delete parsed.arquivo_base64; delete parsed.arquivo_base64;
delete parsed.arquivo; delete parsed.arquivo;
const updatedJustification = JSON.stringify(parsed); const updatedJustification = JSON.stringify(parsed);
const updatedAttendance = (data.attendance || []).map(a => const updatedAttendance = (data.attendance || []).map(a =>
a.id === attendanceForAttachment.id ? { ...a, justification: updatedJustification } : a a.id === attendanceForAttachment.id ? { ...a, justification: updatedJustification } : a
); );
updateData({ attendance: updatedAttendance }); updateData({ attendance: updatedAttendance });
dbService.saveData({ ...data, attendance: updatedAttendance }); dbService.saveData({ ...data, attendance: updatedAttendance });
setViewingAttachment(null); setViewingAttachment(null);
setAttendanceForAttachment(null); setAttendanceForAttachment(null);
showAlert('Sucesso', 'Arquivo removido com sucesso.', 'success'); showAlert('Sucesso', 'Arquivo removido com sucesso.', 'success');
} catch(e) { } catch (e) {
console.error('Erro ao excluir anexo do registro', e); console.error('Erro ao excluir anexo do registro', e);
} }
}; };
@ -171,8 +185,8 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
} }
// Check if there is already a record for this lesson specifically // Check if there is already a record for this lesson specifically
const existingIndex = (data.attendance || []).findIndex(a => const existingIndex = (data.attendance || []).findIndex(a =>
a.studentId === absenceStudentId && a.studentId === absenceStudentId &&
((a as any).lessonId === lesson.id || a.date === `${lesson.date}T${lesson.startTime || '00:00'}:00`) ((a as any).lessonId === lesson.id || a.date === `${lesson.date}T${lesson.startTime || '00:00'}:00`)
); );
@ -217,15 +231,15 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
try { try {
const doc = new jsPDF(); const doc = new jsPDF();
const startY = await addHeader(doc, data); const startY = await addHeader(doc, data);
doc.setFontSize(18); doc.setFontSize(18);
doc.text('Relatório de Frequência', 14, startY + 10); doc.text('Relatório de Frequência', 14, startY + 10);
doc.setFontSize(11); doc.setFontSize(11);
doc.text(`Data: ${new Date(selectedDate).toLocaleDateString()}`, 14, startY + 18); doc.text(`Data: ${new Date(selectedDate).toLocaleDateString()}`, 14, startY + 18);
doc.text(`Turma: ${classObj.name}`, 14, startY + 24); doc.text(`Turma: ${classObj.name}`, 14, startY + 24);
const classAttendance = (data.attendance || []).filter(record => const classAttendance = (data.attendance || []).filter(record =>
record.classId === classObj.id && record.date.startsWith(selectedDate) record.classId === classObj.id && record.date.startsWith(selectedDate)
); );
@ -237,9 +251,9 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
try { try {
const parsed = JSON.parse(justMotivo); const parsed = JSON.parse(justMotivo);
justMotivo = parsed.motivo || justMotivo; justMotivo = parsed.motivo || justMotivo;
} catch(e) {} } catch (e) { }
} }
return [ return [
student?.name || 'Desconhecido', student?.name || 'Desconhecido',
time, time,
@ -270,13 +284,13 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
<p className="text-slate-500 font-medium">Gerencie a frequência por turma e registre faltas justificadas.</p> <p className="text-slate-500 font-medium">Gerencie a frequência por turma e registre faltas justificadas.</p>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<input <input
type="date" type="date"
className="p-2 bg-white border border-slate-200 rounded-lg text-sm font-bold text-slate-700" className="p-2 bg-white border border-slate-200 rounded-lg text-sm font-bold text-slate-700"
value={selectedDate} value={selectedDate}
onChange={e => setSelectedDate(e.target.value)} onChange={e => setSelectedDate(e.target.value)}
/> />
<button <button
onClick={() => setShowAbsenceModal(true)} onClick={() => setShowAbsenceModal(true)}
className="px-4 py-2 bg-amber-500 text-white rounded-lg hover:bg-amber-600 transition-colors font-bold text-sm flex items-center gap-2 shadow-lg shadow-amber-100" className="px-4 py-2 bg-amber-500 text-white rounded-lg hover:bg-amber-600 transition-colors font-bold text-sm flex items-center gap-2 shadow-lg shadow-amber-100"
> >
@ -291,9 +305,9 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
const classStudents = data.students.filter(s => s.classId === classObj.id && s.status === 'active'); const classStudents = data.students.filter(s => s.classId === classObj.id && s.status === 'active');
const attendanceCount = (data.attendance || []).filter(a => a.classId === classObj.id && a.date.startsWith(selectedDate)).length; const attendanceCount = (data.attendance || []).filter(a => a.classId === classObj.id && a.date.startsWith(selectedDate)).length;
const course = data.courses.find(c => c.id === classObj.courseId); const course = data.courses.find(c => c.id === classObj.courseId);
return ( return (
<div <div
key={classObj.id} key={classObj.id}
onClick={() => { onClick={() => {
setSelectedClass(classObj); setSelectedClass(classObj);
@ -302,14 +316,14 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm hover:shadow-xl hover:border-indigo-300 transition-all cursor-pointer group relative overflow-hidden" className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm hover:shadow-xl hover:border-indigo-300 transition-all cursor-pointer group relative overflow-hidden"
> >
<div className="absolute top-0 right-0 w-24 h-24 bg-indigo-50 rounded-full -mr-12 -mt-12 group-hover:scale-150 transition-transform duration-500"></div> <div className="absolute top-0 right-0 w-24 h-24 bg-indigo-50 rounded-full -mr-12 -mt-12 group-hover:scale-150 transition-transform duration-500"></div>
<div className="relative z-10"> <div className="relative z-10">
<div className="w-12 h-12 bg-indigo-100 rounded-xl flex items-center justify-center text-indigo-600 mb-4"> <div className="w-12 h-12 bg-indigo-100 rounded-xl flex items-center justify-center text-indigo-600 mb-4">
<BookOpen size={24} /> <BookOpen size={24} />
</div> </div>
<h3 className="text-xl font-black text-slate-800 mb-1">{classObj.name}</h3> <h3 className="text-xl font-black text-slate-800 mb-1">{classObj.name}</h3>
<p className="text-sm text-slate-500 font-medium mb-4">{course?.name}</p> <p className="text-sm text-slate-500 font-medium mb-4">{course?.name}</p>
<div className="flex items-center justify-between pt-4 border-t border-slate-50"> <div className="flex items-center justify-between pt-4 border-t border-slate-50">
<div className="flex items-center gap-2 text-xs font-bold text-slate-400 uppercase tracking-widest"> <div className="flex items-center gap-2 text-xs font-bold text-slate-400 uppercase tracking-widest">
<User size={14} /> <User size={14} />
@ -330,14 +344,14 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
<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={`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-2xl max-h-[85vh] overflow-hidden shadow-2xl transition-all duration-400 relative flex flex-col ${isClosing ? 'animate-slide-down-fade-out' : 'animate-slide-up'}`}> <div className={`bg-white rounded-3xl w-full max-w-2xl max-h-[85vh] overflow-hidden shadow-2xl transition-all duration-400 relative flex flex-col ${isClosing ? 'animate-slide-down-fade-out' : 'animate-slide-up'}`}>
<div className="bg-indigo-600 h-1.5 w-full absolute top-0 left-0 z-10"></div> <div className="bg-indigo-600 h-1.5 w-full absolute top-0 left-0 z-10"></div>
<div className="p-6 border-b border-slate-100 flex items-center justify-between bg-slate-50/50"> <div className="p-6 border-b border-slate-100 flex items-center justify-between bg-slate-50/50">
<div> <div>
<h3 className="text-xl font-black text-slate-800">Alunos: {selectedClass.name}</h3> <h3 className="text-xl font-black text-slate-800">Alunos: {selectedClass.name}</h3>
<p className="text-sm text-slate-500 font-medium">Clique em um aluno para ver seu histórico individual.</p> <p className="text-sm text-slate-500 font-medium">Clique em um aluno para ver seu histórico individual.</p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
onClick={() => handleExportPDF(selectedClass)} onClick={() => handleExportPDF(selectedClass)}
disabled={isGeneratingPDF} disabled={isGeneratingPDF}
className="p-2 text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition-colors disabled:opacity-50" className="p-2 text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition-colors disabled:opacity-50"
@ -349,7 +363,7 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
<FileDown size={20} /> <FileDown size={20} />
)} )}
</button> </button>
<button <button
onClick={closeModal} onClick={closeModal}
className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg transition-colors" className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg transition-colors"
> >
@ -376,7 +390,7 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
return classStudents.map(student => { return classStudents.map(student => {
const studentActualRecords = (data.attendance || []).filter(a => a.studentId === student.id && a.classId === selectedClass.id); const studentActualRecords = (data.attendance || []).filter(a => a.studentId === student.id && a.classId === selectedClass.id);
const classLessonsRaw = (data.lessons || []).filter(l => l.classId === selectedClass.id && l.status !== 'cancelled'); const classLessonsRaw = (data.lessons || []).filter(l => l.classId === selectedClass.id && l.status !== 'cancelled');
const deduplicatedLessons = classLessonsRaw.filter((lesson, index, self) => const deduplicatedLessons = classLessonsRaw.filter((lesson, index, self) =>
index === self.findIndex((t) => ( index === self.findIndex((t) => (
t.date === lesson.date && t.startTime === lesson.startTime t.date === lesson.date && t.startTime === lesson.startTime
@ -391,7 +405,7 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
deduplicatedLessons.forEach(lesson => { deduplicatedLessons.forEach(lesson => {
const lessonStart = new Date(lesson.date + 'T' + (lesson.startTime || '00:00') + ':00'); const lessonStart = new Date(lesson.date + 'T' + (lesson.startTime || '00:00') + ':00');
const lessonEnd = new Date(lesson.date + 'T' + (lesson.endTime || '23:59') + ':00'); const lessonEnd = new Date(lesson.date + 'T' + (lesson.endTime || '23:59') + ':00');
const presenceStartWindow = new Date(lessonStart.getTime() - 30 * 60 * 1000); const presenceStartWindow = new Date(lessonStart.getTime() - 30 * 60 * 1000);
const matchedRecord = studentActualRecords.find(a => { const matchedRecord = studentActualRecords.find(a => {
if ((a as any).lessonId === lesson.id) return true; if ((a as any).lessonId === lesson.id) return true;
@ -401,19 +415,19 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
}); });
if (matchedRecord) { if (matchedRecord) {
if (matchedRecord.type === 'absence') { if (matchedRecord.type === 'absence') {
if (matchedRecord.justificationAccepted) justified++; if (matchedRecord.justificationAccepted) justified++;
else absences++; else absences++;
} else if (matchedRecord.type === 'presence' || !matchedRecord.type) { } else if (matchedRecord.type === 'presence' || !matchedRecord.type) {
presences++; presences++;
} }
} else if (now > lessonEnd) { } else if (now > lessonEnd) {
absences++; absences++;
} }
}); });
return ( return (
<div <div
key={student.id} key={student.id}
onClick={() => { onClick={() => {
setSelectedStudent(student); setSelectedStudent(student);
@ -473,7 +487,7 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
<p className="text-sm text-slate-500 font-medium">Histórico de Frequência {selectedClass.name}</p> <p className="text-sm text-slate-500 font-medium">Histórico de Frequência {selectedClass.name}</p>
</div> </div>
</div> </div>
<button <button
onClick={closeHistoryModal} onClick={closeHistoryModal}
className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg transition-colors" className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg transition-colors"
> >
@ -491,7 +505,7 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
const actualRecords = (data.attendance || []) const actualRecords = (data.attendance || [])
.filter(a => a.studentId === selectedStudent.id); .filter(a => a.studentId === selectedStudent.id);
const classLessonsRaw = (data.lessons || []) const classLessonsRaw = (data.lessons || [])
.filter(l => studentClassIds.has(l.classId) && l.status !== 'cancelled'); .filter(l => studentClassIds.has(l.classId) && l.status !== 'cancelled');
@ -502,12 +516,12 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
); );
const tableRows: any[] = []; const tableRows: any[] = [];
deduplicatedLessons.forEach(lesson => { deduplicatedLessons.forEach(lesson => {
const lessonStart = new Date(lesson.date + 'T' + (lesson.startTime || '00:00') + ':00'); const lessonStart = new Date(lesson.date + 'T' + (lesson.startTime || '00:00') + ':00');
const lessonEnd = new Date(lesson.date + 'T' + (lesson.endTime || '23:59') + ':00'); const lessonEnd = new Date(lesson.date + 'T' + (lesson.endTime || '23:59') + ':00');
const presenceStartWindow = new Date(lessonStart.getTime() - 30 * 60 * 1000); // 30 mins before const presenceStartWindow = new Date(lessonStart.getTime() - 30 * 60 * 1000); // 30 mins before
let record = actualRecords.find(a => { let record = actualRecords.find(a => {
if ((a as any).lessonId === lesson.id) return true; if ((a as any).lessonId === lesson.id) return true;
if (a.date === `${lesson.date}T${lesson.startTime || '00:00'}:00`) return true; if (a.date === `${lesson.date}T${lesson.startTime || '00:00'}:00`) return true;
@ -528,7 +542,7 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
awaiting: !isFinished awaiting: !isFinished
}; };
} }
if (record) { if (record) {
tableRows.push({ lesson, record }); tableRows.push({ lesson, record });
} }
@ -550,12 +564,12 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
let justified = 0; let justified = 0;
tableRows.forEach(row => { tableRows.forEach(row => {
if (row.record.type === 'absence') { if (row.record.type === 'absence') {
if (row.record.justificationAccepted) justified++; if (row.record.justificationAccepted) justified++;
else absences++; else absences++;
} else if (row.record.type === 'presence' || (!row.record.type && !row.record.isVirtual)) { } else if (row.record.type === 'presence' || (!row.record.type && !row.record.isVirtual)) {
presences++; presences++;
} }
}); });
return ( return (
@ -593,7 +607,7 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-50"> <tbody className="divide-y divide-slate-50">
{tableRows.map(({lesson, record}) => { {tableRows.map(({ lesson, record }) => {
const recordDate = new Date(record.date); const recordDate = new Date(record.date);
const time = recordDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); const time = recordDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
@ -604,7 +618,7 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
const parsed = JSON.parse(justMotivo); const parsed = JSON.parse(justMotivo);
justMotivo = parsed.motivo || justMotivo; justMotivo = parsed.motivo || justMotivo;
justAttachment = parsed.arquivo || parsed.arquivo_base64 || null; justAttachment = parsed.arquivo || parsed.arquivo_base64 || null;
} catch(e) {} } catch (e) { }
} }
const isAbsence = record.type === 'absence'; const isAbsence = record.type === 'absence';
@ -666,7 +680,7 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
{isAwaiting || isPendente ? ( {isAwaiting || isPendente ? (
<span className="text-xs italic text-indigo-400">Aguardando registro ou justificativa...</span> <span className="text-xs italic text-indigo-400">Aguardando registro ou justificativa...</span>
) : justMotivo ? ( ) : justMotivo ? (
<button <button
onClick={() => { onClick={() => {
setCurrentJustificationText(justMotivo); setCurrentJustificationText(justMotivo);
setCurrentRecordForJustification(record); setCurrentRecordForJustification(record);
@ -684,7 +698,7 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
</td> </td>
<td className="px-6 py-4 text-center"> <td className="px-6 py-4 text-center">
{justAttachment ? ( {justAttachment ? (
<button <button
onClick={() => { onClick={() => {
setViewingAttachment(justAttachment!); setViewingAttachment(justAttachment!);
setAttendanceForAttachment(record); setAttendanceForAttachment(record);
@ -701,11 +715,11 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
<td className="px-6 py-4 text-right"> <td className="px-6 py-4 text-right">
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
{(isAbsence || isPendente || isAwaiting) && ( {(isAbsence || isPendente || isAwaiting) && (
<button <button
onClick={() => { onClick={() => {
setAbsenceStudentId(selectedStudent.id); setAbsenceStudentId(selectedStudent.id);
setAbsenceDate(record.date.split('T')[0]); setAbsenceDate(record.date.split('T')[0]);
const lessonId = record.isVirtual ? record.id.replace('v-', '') : const lessonId = record.isVirtual ? record.id.replace('v-', '') :
data.lessons.find(l => l.date === record.date.split('T')[0] && l.classId === record.classId)?.id; data.lessons.find(l => l.date === record.date.split('T')[0] && l.classId === record.classId)?.id;
setAbsenceLessonId(lessonId || ''); setAbsenceLessonId(lessonId || '');
setShowAbsenceModal(true); setShowAbsenceModal(true);
@ -717,7 +731,7 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
)} )}
{hasPendingJustification && ( {hasPendingJustification && (
<button <button
onClick={() => { onClick={() => {
const updated = (data.attendance || []).map(a => a.id === record.id ? { ...a, justificationAccepted: true } : a); const updated = (data.attendance || []).map(a => a.id === record.id ? { ...a, justificationAccepted: true } : a);
updateData({ attendance: updated }); updateData({ attendance: updated });
@ -730,7 +744,7 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
</button> </button>
)} )}
<button <button
onClick={() => toggleAttendanceStatus(record)} onClick={() => toggleAttendanceStatus(record)}
className={`text-[10px] px-2 py-1.5 ${isAbsence || isPendente || isAwaiting ? 'bg-emerald-600 hover:bg-emerald-700' : 'bg-red-600 hover:bg-red-700'} text-white font-bold rounded transition-colors whitespace-nowrap`} className={`text-[10px] px-2 py-1.5 ${isAbsence || isPendente || isAwaiting ? 'bg-emerald-600 hover:bg-emerald-700' : 'bg-red-600 hover:bg-red-700'} text-white font-bold rounded transition-colors whitespace-nowrap`}
> >
@ -758,12 +772,12 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
<div className={`bg-white rounded-3xl w-full max-w-md overflow-hidden shadow-2xl transition-all duration-400 relative ${isClosing ? 'animate-slide-down-fade-out' : 'animate-slide-up'}`}> <div className={`bg-white rounded-3xl w-full max-w-md overflow-hidden shadow-2xl transition-all duration-400 relative ${isClosing ? 'animate-slide-down-fade-out' : 'animate-slide-up'}`}>
{/* Blue Top Bar */} {/* Blue Top Bar */}
<div className="bg-indigo-600 h-1.5 w-full absolute top-0 left-0 z-10"></div> <div className="bg-indigo-600 h-1.5 w-full absolute top-0 left-0 z-10"></div>
<div className="p-6 border-b border-slate-100 flex items-center justify-between bg-amber-50/50"> <div className="p-6 border-b border-slate-100 flex items-center justify-between bg-amber-50/50">
<h3 className="text-xl font-black text-amber-800 flex items-center gap-2"> <h3 className="text-xl font-black text-amber-800 flex items-center gap-2">
<AlertCircle size={24} /> Justificar Falta <AlertCircle size={24} /> Justificar Falta
</h3> </h3>
<button <button
onClick={closeModal} onClick={closeModal}
className="p-2 text-amber-400 hover:text-amber-600 hover:bg-amber-100 rounded-lg transition-colors" className="p-2 text-amber-400 hover:text-amber-600 hover:bg-amber-100 rounded-lg transition-colors"
> >
@ -788,7 +802,7 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5 ml-1">Data</label> <label className="block text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5 ml-1">Data</label>
<input <input
type="date" type="date"
className="w-full px-4 py-2 bg-slate-50 border border-slate-200 rounded-xl text-sm font-bold text-slate-700" className="w-full px-4 py-2 bg-slate-50 border border-slate-200 rounded-xl text-sm font-bold text-slate-700"
value={absenceDate} value={absenceDate}
@ -816,7 +830,7 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
}) })
.map(lesson => { .map(lesson => {
const classObj = data.classes.find(c => c.id === lesson.classId); const classObj = data.classes.find(c => c.id === lesson.classId);
const hasPresence = (data.attendance || []).some(a => const hasPresence = (data.attendance || []).some(a =>
a.studentId === absenceStudentId && a.date.startsWith(lesson.date) && a.type === 'presence' a.studentId === absenceStudentId && a.date.startsWith(lesson.date) && a.type === 'presence'
); );
return ( return (
@ -832,7 +846,7 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
<div> <div>
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5 ml-1">Justificativa</label> <label className="block text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5 ml-1">Justificativa</label>
<textarea <textarea
className="w-full px-4 py-3 bg-slate-50 text-black border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500 transition-all text-sm min-h-[100px]" className="w-full px-4 py-3 bg-slate-50 text-black border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500 transition-all text-sm min-h-[100px]"
placeholder="Informe o motivo da falta..." placeholder="Informe o motivo da falta..."
value={absenceJustification} value={absenceJustification}
@ -840,7 +854,7 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
/> />
</div> </div>
<button <button
onClick={handleAddAbsence} onClick={handleAddAbsence}
className="w-full py-4 bg-amber-500 text-white rounded-2xl font-black text-lg hover:bg-amber-600 shadow-lg shadow-amber-100 flex items-center justify-center gap-2 transition-all active:scale-95" className="w-full py-4 bg-amber-500 text-white rounded-2xl font-black text-lg hover:bg-amber-600 shadow-lg shadow-amber-100 flex items-center justify-center gap-2 transition-all active:scale-95"
> >
@ -859,13 +873,13 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
<FileSignature size={20} className="text-indigo-600" /> Visualização do Documento <FileSignature size={20} className="text-indigo-600" /> Visualização do Documento
</h3> </h3>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
onClick={handleDeleteAttachmentRecord} onClick={handleDeleteAttachmentRecord}
className="px-3 py-1.5 bg-red-50 text-red-600 rounded-lg text-xs font-bold hover:bg-red-100 flex items-center gap-1.5 transition-colors" className="px-3 py-1.5 bg-red-50 text-red-600 rounded-lg text-xs font-bold hover:bg-red-100 flex items-center gap-1.5 transition-colors"
> >
<Trash2 size={14} /> Excluir Arquivo <Trash2 size={14} /> Excluir Arquivo
</button> </button>
<button <button
onClick={() => { setViewingAttachment(null); setAttendanceForAttachment(null); }} onClick={() => { setViewingAttachment(null); setAttendanceForAttachment(null); }}
className="p-2 text-slate-400 hover:bg-slate-200 hover:text-slate-700 rounded-lg transition-colors" className="p-2 text-slate-400 hover:bg-slate-200 hover:text-slate-700 rounded-lg transition-colors"
> >
@ -888,12 +902,12 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
<div className="bg-white rounded-3xl w-full max-w-sm overflow-hidden shadow-2xl animate-in zoom-in-95 slide-in-from-bottom-4 duration-300 relative border border-slate-100"> <div className="bg-white rounded-3xl w-full max-w-sm overflow-hidden shadow-2xl animate-in zoom-in-95 slide-in-from-bottom-4 duration-300 relative border border-slate-100">
{/* Design header */} {/* Design header */}
<div className="bg-amber-500 h-1.5 w-full absolute top-0 left-0"></div> <div className="bg-amber-500 h-1.5 w-full absolute top-0 left-0"></div>
<div className="p-6 border-b border-slate-100 flex items-center justify-between bg-amber-50/30"> <div className="p-6 border-b border-slate-100 flex items-center justify-between bg-amber-50/30">
<h3 className="text-lg font-black text-amber-800 flex items-center gap-2"> <h3 className="text-lg font-black text-amber-800 flex items-center gap-2">
<AlertCircle size={22} /> Motivo da Falta <AlertCircle size={22} /> Motivo da Falta
</h3> </h3>
<button <button
onClick={() => setShowJustificationTextModal(false)} onClick={() => setShowJustificationTextModal(false)}
className="p-2 text-amber-400 hover:text-amber-600 hover:bg-amber-100 rounded-lg transition-colors" className="p-2 text-amber-400 hover:text-amber-600 hover:bg-amber-100 rounded-lg transition-colors"
> >
@ -909,7 +923,7 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
</div> </div>
{currentRecordForJustification && !currentRecordForJustification.justificationAccepted && ( {currentRecordForJustification && !currentRecordForJustification.justificationAccepted && (
<button <button
onClick={() => { onClick={() => {
const updated = (data.attendance || []).map(a => a.id === currentRecordForJustification.id ? { ...a, justificationAccepted: true } : a); const updated = (data.attendance || []).map(a => a.id === currentRecordForJustification.id ? { ...a, justificationAccepted: true } : a);
updateData({ attendance: updated }); updateData({ attendance: updated });
@ -922,7 +936,7 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
<CheckCircle size={20} /> Aceitar Justificativa <CheckCircle size={20} /> Aceitar Justificativa
</button> </button>
)} )}
{currentRecordForJustification?.justificationAccepted && ( {currentRecordForJustification?.justificationAccepted && (
<div className="flex items-center justify-center gap-2 py-4 bg-emerald-50 text-emerald-700 rounded-2xl font-black uppercase text-xs border border-emerald-100"> <div className="flex items-center justify-center gap-2 py-4 bg-emerald-50 text-emerald-700 rounded-2xl font-black uppercase text-xs border border-emerald-100">
<CheckCircle size={18} /> Aceita <CheckCircle size={18} /> Aceita

View File

@ -161,7 +161,8 @@ export interface Attendance {
id: string; id: string;
studentId: string; studentId: string;
classId: string; classId: string;
date: string; // ISO String lessonId?: string;
date: string; // ISO String ou Local ISO
photo?: string; // Base64 (Optional for absences) photo?: string; // Base64 (Optional for absences)
verified: boolean; verified: boolean;
type?: 'presence' | 'absence'; type?: 'presence' | 'absence';

View File

@ -58,7 +58,8 @@ export interface Attendance {
id: string; id: string;
studentId: string; studentId: string;
classId: string; classId: string;
date: string; // ISO String (UTC) lessonId?: string;
date: string; // ISO String (UTC) ou Local ISO
photo?: string; photo?: string;
verified: boolean; verified: boolean;
type?: 'presence' | 'absence'; type?: 'presence' | 'absence';