fix: biometric attendance timezone shift and mandatory lesson binding
This commit is contained in:
parent
1552e5cb19
commit
a1b5075e41
|
|
@ -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"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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} /> Já Aceita
|
<CheckCircle size={18} /> Já Aceita
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue