edumanagerpro2/manager/components/AttendanceQuery.tsx

1001 lines
51 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import { SchoolData, Attendance, Class, Student } from '../types';
import { dbService } from '../services/dbService';
import { useDialog } from '../DialogContext';
import { Search, Calendar, User, Clock, CheckCircle, XCircle, FileDown, BookOpen, Plus, X, AlertCircle, RefreshCw, ChevronRight, Trash2, FileSignature, Paperclip, Eye } from 'lucide-react';
import jsPDF from 'jspdf';
import 'jspdf-autotable';
import { addHeader } from '../services/pdfService';
import SearchableSelect from './SearchableSelect';
interface AttendanceQueryProps {
data: SchoolData;
updateData: (newData: Partial<SchoolData>) => void;
deepLinkStudentId?: string | null;
clearDeepLink?: () => void;
}
const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, deepLinkStudentId, clearDeepLink }) => {
const { showAlert } = useDialog();
useEffect(() => {
if (deepLinkStudentId) {
const student = data.students.find(s => s.id === deepLinkStudentId);
if (student) {
const classObj = data.classes.find(c => c.id === student.classId);
if (classObj) {
setSelectedClass(classObj);
setSelectedStudent(student);
setShowStudentHistoryModal(true);
if (clearDeepLink) clearDeepLink();
}
}
}
}, [deepLinkStudentId, data.students, data.classes, clearDeepLink]);
const [selectedClass, setSelectedClass] = useState<Class | null>(null);
const [showStudentListModal, setShowStudentListModal] = useState(false);
const [selectedStudent, setSelectedStudent] = useState<Student | null>(null);
const [showStudentHistoryModal, setShowStudentHistoryModal] = useState(false);
const [showAbsenceModal, setShowAbsenceModal] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const [isClosing2, setIsClosing2] = useState(false);
// 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 [absenceStudentId, setAbsenceStudentId] = useState('');
const [absenceJustification, setAbsenceJustification] = useState('');
const [absenceDate, setAbsenceDate] = useState(getTodayLocal());
const [absenceLessonId, setAbsenceLessonId] = useState('');
const [viewingAttachment, setViewingAttachment] = useState<string | null>(null);
const [attendanceForAttachment, setAttendanceForAttachment] = useState<Attendance | null>(null);
const [showJustificationTextModal, setShowJustificationTextModal] = useState(false);
const [currentJustificationText, setCurrentJustificationText] = useState('');
const [currentRecordForJustification, setCurrentRecordForJustification] = useState<Attendance | null>(null);
const [dbAttendance, setDbAttendance] = useState<Attendance[]>(data.attendance || []);
const loadAttendance = async () => {
try {
const res = await fetch('/api/frequencias');
if (res.ok) {
const json = await res.json();
if (json.frequencias) {
setDbAttendance(json.frequencias.map((r: any) => ({
id: r.id,
studentId: r.studentId || r.aluno_id,
classId: r.classId || r.turma_id,
lessonId: r.lessonId || r.aula_id,
date: r.date || r.data,
photo: r.photo || r.foto || r.foto_url,
verified: r.verified ?? r.verificado ?? false,
type: r.type || r.tipo || 'presence',
justification: r.justification || r.justificativa,
justificationAccepted: r.justificationAccepted ?? r.justificativa_aceita ?? false,
createdAt: r.createdAt || r.created_at
})));
}
}
} catch (e) {
console.error('Erro ao carregar frequencias do SQL:', e);
}
};
useEffect(() => {
loadAttendance();
}, []);
// Helper para normalizar URLs de fotos (vacina contra cache antigo)
const normalizePhotoUrl = (url?: string) => {
if (!url || typeof url !== 'string') return '';
if (url.startsWith('data:image') || url.startsWith('blob:')) return url;
if (url.startsWith('/storage/')) return url;
try {
const match = url.match(/^https?:\/\/[^\/]+\/(.+)$/);
if (match) return `/storage/${match[1]}`;
} catch (e) { }
return url;
};
const toggleAttendanceStatus = async (record: any) => {
let updatedAttendance = [...dbAttendance];
if (record.isVirtual) {
// 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 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 = {
id: crypto.randomUUID(),
studentId: record.studentId,
classId: record.classId,
date: lesson ? `${lesson.date}T${lesson.startTime || '00:00'}:00` : localDate,
verified: true,
type: newType,
...(lesson ? { lessonId: lesson.id } : {}) // Vinculação rígida para não haver duplicidade
};
// Garantir que não duplica se já houver por algum erro
const existingIdx = updatedAttendance.findIndex(a =>
a.studentId === record.studentId &&
((a as any).lessonId === lesson?.id || a.date === newRecord.date)
);
if (existingIdx >= 0) {
updatedAttendance[existingIdx] = { ...updatedAttendance[existingIdx], type: newType, justification: undefined, justificationAccepted: undefined };
await fetch(`/api/frequencias/${updatedAttendance[existingIdx].id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updatedAttendance[existingIdx]) });
} else {
updatedAttendance.push(newRecord);
await fetch('/api/frequencias', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(newRecord) });
}
} else {
// Toggle existing record
const newType = record.type === 'absence' ? 'presence' : 'absence';
const modifiedRecord = { ...record, type: newType, justification: undefined, justificationAccepted: undefined };
updatedAttendance = updatedAttendance.map(a =>
a.id === record.id ? modifiedRecord : a
);
await fetch(`/api/frequencias/${record.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(modifiedRecord) });
}
setDbAttendance(updatedAttendance);
showAlert('Sucesso', 'Status de frequência atualizado com sucesso.', 'success');
};
const handleDeleteAttachmentRecord = async () => {
if (!attendanceForAttachment || !attendanceForAttachment.justification) return;
try {
const parsed = JSON.parse(attendanceForAttachment.justification);
delete parsed.arquivo_base64;
delete parsed.arquivo;
const updatedJustification = JSON.stringify(parsed);
const updatedAttendance = dbAttendance.map(a =>
a.id === attendanceForAttachment.id ? { ...a, justification: updatedJustification } : a
);
const modifiedRecord = updatedAttendance.find(a => a.id === attendanceForAttachment.id);
await fetch(`/api/frequencias/${attendanceForAttachment.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(modifiedRecord) });
setDbAttendance(updatedAttendance);
setViewingAttachment(null);
setAttendanceForAttachment(null);
showAlert('Sucesso', 'Arquivo removido com sucesso.', 'success');
} catch (e) {
console.error('Erro ao excluir anexo do registro', e);
}
};
const closeModal = () => {
setIsClosing(true);
setTimeout(() => {
setShowStudentListModal(false);
setShowAbsenceModal(false);
setIsClosing(false);
setAbsenceStudentId('');
setAbsenceJustification('');
setAbsenceLessonId('');
}, 400);
};
const closeHistoryModal = () => {
setIsClosing2(true);
setTimeout(() => {
setShowStudentHistoryModal(false);
setSelectedStudent(null);
setIsClosing2(false);
}, 400);
};
const handleAddAbsence = async () => {
if (!absenceStudentId || !absenceJustification || !absenceLessonId) {
showAlert('Atenção', "⚠️ Por favor, preencha todos os campos da justificativa.", 'warning');
return;
}
const student = data.students.find(s => s.id === absenceStudentId);
if (!student) {
showAlert('Erro', "Aluno não encontrado.", 'error');
return;
}
const lesson = data.lessons.find(l => l.id === absenceLessonId);
if (!lesson) {
showAlert('Atenção', "⚠️ Por favor, selecione a aula para justificar.", 'warning');
return;
}
// Check if there is already a record for this lesson specifically
const existingIndex = dbAttendance.findIndex(a =>
a.studentId === absenceStudentId &&
((a as any).lessonId === lesson.id || a.date === `${lesson.date}T${lesson.startTime || '00:00'}:00`)
);
let updatedAttendance = [...dbAttendance];
if (existingIndex >= 0) {
updatedAttendance[existingIndex] = {
...updatedAttendance[existingIndex],
type: 'absence',
justification: absenceJustification,
justificationAccepted: true,
verified: true,
lessonId: lesson.id as any
};
await fetch(`/api/frequencias/${updatedAttendance[existingIndex].id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updatedAttendance[existingIndex]) });
} else {
const newAbsence: Attendance = {
id: crypto.randomUUID(),
studentId: absenceStudentId,
classId: student.classId,
date: `${lesson.date}T${lesson.startTime || '00:00'}:00`,
verified: true,
type: 'absence',
justification: absenceJustification,
justificationAccepted: true,
...(lesson ? { lessonId: lesson.id } : {}) as any
};
updatedAttendance.push(newAbsence);
await fetch('/api/frequencias', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(newAbsence) });
}
setDbAttendance(updatedAttendance);
setAbsenceStudentId('');
setAbsenceJustification('');
setAbsenceLessonId('');
closeModal();
showAlert('Sucesso', "Falta justificada registrada com sucesso!", 'success');
};
const handleExportPDF = async (classObj: Class) => {
setIsGeneratingPDF(true);
try {
const doc = new jsPDF();
const startY = await addHeader(doc, data);
doc.setFontSize(18);
doc.text('Relatório de Frequência', 14, startY + 10);
doc.setFontSize(11);
doc.text(`Data: ${new Date(selectedDate).toLocaleDateString()}`, 14, startY + 18);
doc.text(`Turma: ${classObj.name}`, 14, startY + 24);
const classAttendance = dbAttendance.filter(record =>
record.classId === classObj.id && record.date.startsWith(selectedDate)
);
const tableData = classAttendance.map(record => {
const student = data.students.find(s => s.id === record.studentId);
const time = new Date(record.date).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
let justMotivo = record.justification || '-';
if (justMotivo.startsWith('{')) {
try {
const parsed = JSON.parse(justMotivo);
justMotivo = parsed.motivo || justMotivo;
} catch (e) { }
}
return [
student?.name || 'Desconhecido',
time,
record.type === 'absence' ? (record.justificationAccepted ? 'Falta Justificada' : 'Falta') : 'Presente',
justMotivo
];
});
(doc as any).autoTable({
startY: startY + 30,
head: [['Aluno', 'Horário', 'Status', 'Justificativa']],
body: tableData,
});
doc.save(`frequencia_${classObj.name}_${selectedDate}.pdf`);
} catch (error) {
console.error('Error exporting PDF:', error);
} finally {
setIsGeneratingPDF(false);
}
};
return (
<div className="space-y-8 animate-in fade-in duration-300 pb-20">
<header className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h2 className="text-3xl font-extrabold text-slate-900 tracking-tight">Registro de Frequência</h2>
<p className="text-slate-500 font-medium">Gerencie a frequência por turma e registre faltas justificadas.</p>
</div>
<div className="flex items-center gap-3">
<input
type="date"
className="p-2 bg-white border border-slate-200 rounded-lg text-sm font-bold text-slate-700"
value={selectedDate}
onChange={e => setSelectedDate(e.target.value)}
/>
<button
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"
>
<Plus size={18} /> Justificar Falta
</button>
</div>
</header>
{/* Class Cards Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{data.classes.map(classObj => {
const classStudents = data.students.filter(s => s.classId === classObj.id && s.status === 'active');
const attendanceCount = dbAttendance.filter(a => a.classId === classObj.id && a.date.startsWith(selectedDate)).length;
const course = data.courses.find(c => c.id === classObj.courseId);
return (
<div
key={classObj.id}
onClick={() => {
setSelectedClass(classObj);
setShowStudentListModal(true);
}}
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="relative z-10">
<div className="w-12 h-12 bg-indigo-100 rounded-xl flex items-center justify-center text-indigo-600 mb-4">
<BookOpen size={24} />
</div>
<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>
<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">
<User size={14} />
{classStudents.length} Alunos {attendanceCount} Registros
</div>
<div className="text-indigo-600 font-bold text-xs flex items-center gap-1 group-hover:translate-x-1 transition-transform">
Ver Alunos <ChevronRight size={14} />
</div>
</div>
</div>
</div>
);
})}
</div>
{/* === MODAL 1: Lista de Alunos da Turma === */}
{showStudentListModal && selectedClass && (
<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-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>
<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>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleExportPDF(selectedClass)}
disabled={isGeneratingPDF}
className="p-2 text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition-colors disabled:opacity-50"
title="Exportar PDF"
>
{isGeneratingPDF ? (
<RefreshCw size={20} className="animate-spin" />
) : (
<FileDown size={20} />
)}
</button>
<button
onClick={closeModal}
className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg transition-colors"
>
<X size={20} />
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-2">
{(() => {
const classStudents = data.students
.filter(s => s.classId === selectedClass.id && s.status === 'active')
.sort((a, b) => a.name.localeCompare(b.name));
if (classStudents.length === 0) {
return (
<div className="text-center py-12 text-slate-400">
<User size={48} className="mx-auto mb-4 opacity-20" />
<p className="font-bold">Nenhum aluno ativo nesta turma.</p>
</div>
);
}
return classStudents.map(student => {
const studentActualRecords = dbAttendance.filter(a => a.studentId === student.id && a.classId === selectedClass.id);
const classLessonsRaw = (data.lessons || []).filter(l => l.classId === selectedClass.id && l.status !== 'cancelled');
const deduplicatedLessons = classLessonsRaw.filter((lesson, index, self) =>
index === self.findIndex((t) => (
t.date === lesson.date && t.startTime === lesson.startTime
))
);
let presences = 0;
let absences = 0;
let justified = 0;
const now = new Date();
deduplicatedLessons.forEach(lesson => {
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 matchingRecords = studentActualRecords.filter(a => {
if ((a as any).lessonId === lesson.id) return true;
if (a.date === `${lesson.date}T${lesson.startTime || '00:00'}:00`) return true;
const recordTime = new Date(a.date);
return recordTime >= lessonStart && recordTime <= lessonEnd;
});
const matchedRecord = matchingRecords.find(a => a.type === 'presence' || !a.type) ||
matchingRecords.find(a => a.type === 'absence' && a.justificationAccepted) ||
matchingRecords[0];
if (matchedRecord) {
if (matchedRecord.type === 'absence') {
if (matchedRecord.justificationAccepted) justified++;
else absences++;
} else if (matchedRecord.type === 'presence' || !matchedRecord.type) {
presences++;
}
} else if (now > lessonEnd) {
absences++;
}
});
return (
<div
key={student.id}
onClick={() => {
setSelectedStudent(student);
setShowStudentHistoryModal(true);
}}
className="flex items-center justify-between p-4 bg-slate-50 hover:bg-indigo-50 rounded-xl border border-slate-100 hover:border-indigo-200 cursor-pointer transition-all group"
>
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-slate-100 overflow-hidden border border-slate-200 flex items-center justify-center text-indigo-600 font-black text-sm flex-shrink-0">
{student.photo ? (
<img src={normalizePhotoUrl(student.photo)} alt={student.name} className="w-full h-full object-cover" />
) : (
student.name.charAt(0).toUpperCase()
)}
</div>
<div>
<p className="font-bold text-slate-800 text-sm">{student.name}</p>
<p className="text-[10px] text-slate-500">Matrícula: {student.enrollmentNumber || '—'}</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex gap-1.5 text-[10px] font-bold">
<span className="px-1.5 py-0.5 bg-emerald-100 text-emerald-700 rounded">{presences}P</span>
<span className="px-1.5 py-0.5 bg-red-100 text-red-700 rounded">{absences}F</span>
{justified > 0 && <span className="px-1.5 py-0.5 bg-amber-100 text-amber-700 rounded">{justified}J</span>}
</div>
<ChevronRight size={16} className="text-slate-300 group-hover:text-indigo-500 group-hover:translate-x-1 transition-all" />
</div>
</div>
);
});
})()}
</div>
</div>
</div>
)}
{/* === MODAL 2: Histórico Individual do Aluno === */}
{showStudentHistoryModal && selectedStudent && selectedClass && (
<div className={`fixed inset-0 bg-transparent z-[60] flex items-center justify-center p-4 transition-opacity duration-400 ${isClosing2 ? 'opacity-0' : 'opacity-100 animate-in fade-in'}`}>
<div className={`bg-white rounded-3xl w-full max-w-5xl max-h-[90vh] overflow-hidden shadow-2xl transition-all duration-400 relative flex flex-col ${isClosing2 ? '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="p-6 border-b border-slate-100 flex items-center justify-between bg-slate-50/50">
<div className="flex items-center gap-4">
<div className="w-20 h-20 rounded-full bg-slate-50 border-4 border-white shadow-xl overflow-hidden flex-shrink-0">
{selectedStudent.photo ? (
<img src={normalizePhotoUrl(selectedStudent.photo)} alt={selectedStudent.name} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center bg-indigo-100 text-indigo-600 font-black text-2xl">
{selectedStudent.name.charAt(0).toUpperCase()}
</div>
)}
</div>
<div>
<h3 className="text-xl font-black text-slate-800">{selectedStudent.name}</h3>
<p className="text-sm text-slate-500 font-medium">Histórico de Frequência {selectedClass.name}</p>
</div>
</div>
<button
onClick={closeHistoryModal}
className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg transition-colors"
>
<X size={20} />
</button>
</div>
<div className="flex-1 overflow-y-auto">
{(() => {
const now = new Date();
const studentClassIds = new Set([
selectedClass.id,
...dbAttendance.filter(a => a.studentId === selectedStudent.id).map(a => a.classId)
].filter(Boolean));
const actualRecords = dbAttendance
.filter(a => a.studentId === selectedStudent.id);
const classLessonsRaw = (data.lessons || [])
.filter(l => studentClassIds.has(l.classId) && l.status !== 'cancelled');
const deduplicatedLessons = classLessonsRaw.filter((lesson, index, self) =>
index === self.findIndex((t) => (
t.date === lesson.date && t.startTime === lesson.startTime
))
);
const tableRows: any[] = [];
deduplicatedLessons.forEach(lesson => {
const lessonStart = new Date(lesson.date + 'T' + (lesson.startTime || '00:00') + ':00');
const lessonEnd = new Date(lesson.date + 'T' + (lesson.endTime || '23:59') + ':00');
// Regra Estrita: Comparação exata com o horário da aula (sem 30 min de tolerância)
const matchingRecords = actualRecords.filter(a => {
if ((a as any).lessonId === lesson.id) return true;
if (a.date === `${lesson.date}T${lesson.startTime || '00:00'}:00`) return true;
const recordTime = new Date(a.date);
return recordTime >= lessonStart && recordTime <= lessonEnd;
});
let record = matchingRecords.find(a => a.type === 'presence' || !a.type) ||
matchingRecords.find(a => a.type === 'absence' && a.justificationAccepted) ||
matchingRecords[0];
if (!record && now >= lessonStart) {
const isFinished = now > lessonEnd;
record = {
id: `v-${lesson.id}`,
studentId: selectedStudent.id,
classId: lesson.classId || selectedClass.id,
date: `${lesson.date}T${lesson.startTime || '00:00'}:00`,
type: isFinished ? 'absence' : 'awaiting',
isVirtual: true,
lessonId: lesson.id,
awaiting: !isFinished
};
}
if (record) {
tableRows.push({ lesson, record });
}
});
tableRows.sort((a, b) => new Date(b.lesson.date + 'T' + (b.lesson.startTime || '00:00') + ':00').getTime() - new Date(a.lesson.date + 'T' + (a.lesson.startTime || '00:00') + ':00').getTime());
if (tableRows.length === 0) {
return (
<div className="text-center py-16 text-slate-400">
<Calendar size={48} className="mx-auto mb-4 opacity-20" />
<p className="font-bold">Nenhum registro de frequência ou aula agendada.</p>
</div>
);
}
let presences = 0;
let absences = 0;
let justified = 0;
tableRows.forEach(row => {
if (row.record.type === 'absence') {
if (row.record.justificationAccepted) justified++;
else absences++;
} else if (row.record.type === 'presence' || (!row.record.type && !row.record.isVirtual)) {
presences++;
}
});
return (
<>
{/* Summary bar */}
<div className="p-4 bg-slate-50 border-b border-slate-100 flex flex-wrap gap-4 text-sm font-bold">
<div className="flex items-center gap-2 px-3 py-1.5 bg-emerald-100 text-emerald-700 rounded-lg">
<CheckCircle size={16} /> {presences} Presenças
</div>
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-red-100 text-red-700 rounded-lg">
<XCircle size={14} /> {absences} Faltas
</div>
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-amber-100 text-amber-700 rounded-lg">
<AlertCircle size={14} /> {justified} Justificadas
</div>
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-indigo-100 text-indigo-700 rounded-lg">
<BookOpen size={14} /> {tableRows.length} Aulas
</div>
</div>
{/* Attendance table */}
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead className="bg-slate-50 text-slate-500 text-[10px] uppercase font-bold tracking-wider sticky top-0">
<tr>
<th className="px-6 py-4 text-sm">Data</th>
<th className="px-6 py-4 text-sm">Turma</th>
<th className="px-6 py-4 text-sm">Início (Aula)</th>
<th className="px-6 py-4 text-sm">Término (Aula)</th>
<th className="px-6 py-4 text-sm">Registro</th>
<th className="px-6 py-4 text-sm">Status</th>
<th className="px-6 py-4 text-sm">Justificativa</th>
<th className="px-6 py-4 text-sm text-center">Anexo</th>
<th className="px-6 py-4 text-sm text-right">Ação</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-50">
{tableRows.map(({ lesson, record }) => {
const recordDate = new Date(record.date);
const time = recordDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
let justMotivo = record.justification || '';
let justAttachment: string | null = null;
if (justMotivo.startsWith('{')) {
try {
const parsed = JSON.parse(justMotivo);
justMotivo = parsed.motivo || justMotivo;
justAttachment = parsed.arquivo || parsed.arquivo_base64 || null;
} catch (e) { }
}
const isAbsence = record.type === 'absence';
const isAwaiting = record.type === 'awaiting';
const isJustified = isAbsence && record.justificationAccepted;
const hasPendingJustification = isAbsence && record.justification && !record.justificationAccepted;
const isPendente = record.awaiting;
return (
<tr key={record.id} className="hover:bg-slate-50/50 transition-colors">
<td className="px-6 py-4 text-base font-bold text-slate-800">
{recordDate.toLocaleDateString('pt-BR')}
</td>
<td className="px-6 py-4 text-sm font-bold text-slate-600">
{data.classes.find(c => c.id === lesson?.classId)?.name || '—'}
</td>
<td className="px-6 py-4 text-sm font-bold text-indigo-600">
<div className="flex items-center gap-1.5">
<Clock size={14} className="text-slate-400" /> {lesson?.startTime || '--:--'}
</div>
</td>
<td className="px-6 py-4 text-sm font-bold text-indigo-600">
<div className="flex items-center gap-1.5">
<Clock size={14} className="text-slate-400" /> {lesson?.endTime || '--:--'}
</div>
</td>
<td className="px-6 py-4 text-sm text-slate-500 font-medium">
<div className="flex items-center gap-1.5">
<Clock size={14} /> {record.isVirtual ? "--:--" : time}
</div>
</td>
<td className="px-6 py-4">
{isAwaiting ? (
<span className="px-3 py-1.5 bg-indigo-50 text-indigo-600 rounded-full text-xs font-black uppercase tracking-wider inline-flex items-center gap-1.5 animate-pulse">
<Clock size={12} /> Aguardando Justificativa
</span>
) : isJustified ? (
<span className="px-3 py-1.5 bg-amber-100 text-amber-700 rounded-full text-xs font-black uppercase tracking-wider inline-flex items-center gap-1.5">
<AlertCircle size={12} /> Falta Justificada
</span>
) : (isAbsence || isPendente) ? (
<div className="flex flex-col gap-1">
<span className="px-3 py-1.5 bg-red-100 text-red-700 rounded-full text-xs font-black uppercase tracking-wider inline-flex items-center gap-1.5">
<XCircle size={12} /> Falta
</span>
{isPendente && (
<span className="text-[9px] font-bold text-amber-600 uppercase flex items-center gap-1 px-1">
<Clock size={10} /> Aguardando Justificativa
</span>
)}
</div>
) : (
<span className="px-3 py-1.5 bg-emerald-100 text-emerald-700 rounded-full text-xs font-black uppercase tracking-wider inline-flex items-center gap-1.5">
<CheckCircle size={12} /> Presente
</span>
)}
</td>
<td className="px-6 py-4">
{isAwaiting || isPendente ? (
<span className="text-xs italic text-indigo-400">Aguardando registro ou justificativa...</span>
) : justMotivo ? (
<button
onClick={() => {
setCurrentJustificationText(justMotivo);
setCurrentRecordForJustification(record);
setShowJustificationTextModal(true);
}}
className="p-2 text-amber-600 bg-amber-50 hover:bg-amber-100 rounded-xl transition-all shadow-sm border border-amber-100 flex items-center gap-2 group"
title="Ver Justificativa Completa"
>
<Eye size={18} className="group-hover:scale-110 transition-transform" />
<span className="text-[10px] font-bold uppercase tracking-wider">Ver Motivo</span>
</button>
) : (
<span className="text-sm text-slate-300"></span>
)}
</td>
<td className="px-6 py-4 text-center">
{justAttachment ? (
<button
onClick={() => {
setViewingAttachment(justAttachment!);
setAttendanceForAttachment(record);
}}
className="p-2 text-indigo-600 bg-indigo-50 hover:bg-indigo-100 rounded-xl transition-all animate-pulse shadow-md border border-indigo-200"
title="Ver Anexo"
>
<Paperclip size={18} />
</button>
) : (
<span className="text-sm text-slate-200"><Paperclip size={18} /></span>
)}
</td>
<td className="px-6 py-4 text-right">
<div className="flex justify-end gap-2">
{(isAbsence || isPendente || isAwaiting) && (
<button
onClick={() => {
setAbsenceStudentId(selectedStudent.id);
setAbsenceDate(record.date.split('T')[0]);
const lessonId = record.isVirtual ? record.id.replace('v-', '') :
data.lessons.find(l => l.date === record.date.split('T')[0] && l.classId === record.classId)?.id;
setAbsenceLessonId(lessonId || '');
setShowAbsenceModal(true);
}}
className="text-[10px] px-2 py-1.5 bg-amber-500 text-white font-bold rounded hover:bg-amber-600 transition-colors"
>
Justificar
</button>
)}
{hasPendingJustification && (
<button
onClick={async () => {
const updated = dbAttendance.map(a => a.id === record.id ? { ...a, justificationAccepted: true } : a);
const modifiedRecord = updated.find(a => a.id === record.id);
await fetch(`/api/frequencias/${record.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(modifiedRecord) });
setDbAttendance(updated);
showAlert('Sucesso', 'Justificativa aceita com sucesso.', 'success');
}}
className="text-[10px] px-2 py-1.5 bg-indigo-600 text-white font-bold rounded hover:bg-indigo-700 transition-colors"
>
Aceitar
</button>
)}
<button
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`}
>
{isAbsence || isPendente || isAwaiting ? 'Marcar Presença' : 'Marcar Falta'}
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</>
);
})()}
</div>
</div>
</div>
)}
{/* Justified Absence Modal */}
{showAbsenceModal && (
<div className={`fixed inset-0 bg-transparent z-[70] 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-md 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-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">
<AlertCircle size={24} /> Justificar Falta
</h3>
<button
onClick={closeModal}
className="p-2 text-amber-400 hover:text-amber-600 hover:bg-amber-100 rounded-lg transition-colors"
>
<X size={20} />
</button>
</div>
<div className="p-6 space-y-4">
<div>
<SearchableSelect
label="Aluno"
placeholder="Selecione ou digite o nome do aluno..."
value={absenceStudentId}
onChange={(val) => setAbsenceStudentId(val)}
options={data.students
.filter(s => s.status === 'active')
.sort((a, b) => a.name.localeCompare(b.name))
.map(student => ({ id: student.id, name: student.name }))}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5 ml-1">Data</label>
<input
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"
value={absenceDate}
onChange={e => {
setAbsenceDate(e.target.value);
setAbsenceLessonId('');
}}
/>
</div>
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5 ml-1">Aula/Horário</label>
<select
className="w-full px-4 py-2 bg-slate-50 border border-slate-200 rounded-xl text-sm font-bold text-slate-700"
value={absenceLessonId}
onChange={e => setAbsenceLessonId(e.target.value)}
>
<option value="">Selecione...</option>
{data.lessons
.filter(l => l.date === absenceDate && l.status !== 'cancelled')
.filter(l => {
// If student selected, filter lessons matching student's class or any class student is in
if (!absenceStudentId) return true;
const student = data.students.find(s => s.id === absenceStudentId);
return student && l.classId === student.classId;
})
.map(lesson => {
const classObj = data.classes.find(c => c.id === lesson.classId);
const hasPresence = (data.attendance || []).some(a =>
a.studentId === absenceStudentId && a.date.startsWith(lesson.date) && a.type === 'presence'
);
return (
<option key={lesson.id} value={lesson.id} disabled={hasPresence}>
{lesson.startTime || '--:--'} - {classObj?.name} {hasPresence ? '(Presente)' : ''}
</option>
);
})
}
</select>
</div>
</div>
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5 ml-1">Justificativa</label>
<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]"
placeholder="Informe o motivo da falta..."
value={absenceJustification}
onChange={(e) => setAbsenceJustification(e.target.value)}
/>
</div>
<button
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"
>
Salvar Justificativa
</button>
</div>
</div>
</div>
)}
{viewingAttachment && (
<div className="fixed inset-0 bg-transparent z-[100] flex items-center justify-center p-4">
<div className="bg-white rounded-2xl w-full max-w-4xl max-h-[90vh] flex flex-col overflow-hidden shadow-2xl animate-in zoom-in-95 duration-200">
<div className="p-4 border-b flex items-center justify-between bg-slate-50">
<h3 className="font-black text-slate-800 flex items-center gap-2">
<FileSignature size={20} className="text-indigo-600" /> Visualização do Documento
</h3>
<div className="flex items-center gap-2">
<button
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"
>
<Trash2 size={14} /> Excluir Arquivo
</button>
<button
onClick={() => { setViewingAttachment(null); setAttendanceForAttachment(null); }}
className="p-2 text-slate-400 hover:bg-slate-200 hover:text-slate-700 rounded-lg transition-colors"
>
<X size={20} />
</button>
</div>
</div>
<div className="flex-1 overflow-auto bg-slate-200 p-4 flex items-center justify-center">
{viewingAttachment.includes('pdf') ? (
<iframe src={normalizePhotoUrl(viewingAttachment)} className="w-full h-full min-h-[70vh] rounded-lg shadow-sm border border-slate-100" />
) : (
<img src={normalizePhotoUrl(viewingAttachment)} className="max-w-full max-h-full object-contain rounded-lg shadow-xl" alt="Documento" />
)}
</div>
</div>
</div>
)}
{showJustificationTextModal && (
<div className="fixed inset-0 bg-transparent z-[110] flex items-center justify-center p-4 animate-in fade-in duration-300">
<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 */}
<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">
<h3 className="text-lg font-black text-amber-800 flex items-center gap-2">
<AlertCircle size={22} /> Motivo da Falta
</h3>
<button
onClick={() => setShowJustificationTextModal(false)}
className="p-2 text-amber-400 hover:text-amber-600 hover:bg-amber-100 rounded-lg transition-colors"
>
<X size={20} />
</button>
</div>
<div className="p-8">
<div className="bg-slate-50 p-6 rounded-2xl border border-slate-100 mb-6 max-h-[300px] overflow-y-auto">
<p className="text-slate-700 font-medium leading-relaxed italic text-center">
"{currentJustificationText}"
</p>
</div>
{currentRecordForJustification && !currentRecordForJustification.justificationAccepted && (
<button
onClick={() => {
const updated = (data.attendance || []).map(a => a.id === currentRecordForJustification.id ? { ...a, justificationAccepted: true } : a);
const modifiedRecord = updated.find(a => a.id === currentRecordForJustification.id);
fetch(`/api/frequencias/${currentRecordForJustification.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(modifiedRecord) });
updateData({ attendance: updated });
dbService.saveData({ ...data, attendance: updated });
showAlert('Sucesso', 'Justificativa aceita com sucesso.', 'success');
setShowJustificationTextModal(false);
}}
className="w-full py-4 bg-indigo-600 text-white rounded-2xl font-black text-base hover:bg-indigo-700 shadow-lg shadow-indigo-100 flex items-center justify-center gap-2 transition-all active:scale-95"
>
<CheckCircle size={20} /> Aceitar Justificativa
</button>
)}
{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">
<CheckCircle size={18} /> Aceita
</div>
)}
</div>
</div>
</div>
)}
</div>
);
};
export default AttendanceQuery;