feat: enforce single attendance justification, automatic contract registration in SQL, default contract checks and class frequency PDF report
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 2m17s Details

This commit is contained in:
Sidney 2026-05-26 21:23:58 -03:00
parent 3d683ac68a
commit 4a922f43ec
4 changed files with 127 additions and 40 deletions

View File

@ -271,42 +271,78 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
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('Relatorio Geral de Frequencia da Turma', 14, startY + 10);
doc.setFontSize(11); doc.setFontSize(11);
doc.text(`Data: ${new Date(selectedDate).toLocaleDateString()}`, 14, startY + 18); doc.text(`Turma: ${classObj.name}`, 14, startY + 18);
doc.text(`Turma: ${classObj.name}`, 14, startY + 24); doc.text(`Data de Emissao: ${new Date().toLocaleDateString('pt-BR')}`, 14, startY + 24);
const classAttendance = dbAttendance.filter(record => const classStudents = data.students
record.classId === classObj.id && record.date.startsWith(selectedDate) .filter(s => s.classId === classObj.id && s.status === 'active')
.sort((a, b) => a.name.localeCompare(b.name));
const tableData = classStudents.map(student => {
const studentActualRecords = dbAttendance.filter(a => a.studentId === student.id && a.classId === classObj.id);
const classLessonsRaw = (data.lessons || []).filter(l => l.classId === classObj.id && l.status !== 'cancelled');
const deduplicatedLessons = classLessonsRaw.filter((lesson, index, self) =>
index === self.findIndex((t) => (
t.date === lesson.date && t.startTime === lesson.startTime
))
); );
const tableData = classAttendance.map(record => { let presences = 0;
const student = data.students.find(s => s.id === record.studentId); let absences = 0;
const time = new Date(record.date).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); let justified = 0;
let justMotivo = record.justification || '-'; const now = new Date();
if (justMotivo.startsWith('{')) {
try { deduplicatedLessons.forEach(lesson => {
const parsed = JSON.parse(justMotivo); const lessonStart = new Date(lesson.date + 'T' + (lesson.startTime || '00:00') + ':00');
justMotivo = parsed.motivo || justMotivo; const lessonEnd = new Date(lesson.date + 'T' + (lesson.endTime || '23:59') + ':00');
} catch (e) { }
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++;
}
});
const totalWithJustified = presences + absences + justified;
const attendanceRate = totalWithJustified > 0 ? ((presences + justified) / totalWithJustified) * 100 : 100;
return [ return [
student?.name || 'Desconhecido', student.name,
time, student.enrollmentNumber || '—',
record.type === 'absence' ? (record.justificationAccepted ? 'Falta Justificada' : 'Falta') : 'Presente', `${presences} P`,
justMotivo `${absences} F`,
`${justified} J`,
`${attendanceRate.toFixed(0)}%`
]; ];
}); });
(doc as any).autoTable({ (doc as any).autoTable({
startY: startY + 30, startY: startY + 30,
head: [['Aluno', 'Horário', 'Status', 'Justificativa']], head: [['Aluno', 'Matricula', 'Presencas', 'Faltas', 'Justificadas', 'Frequencia %']],
body: tableData, body: tableData,
}); });
doc.save(`frequencia_${classObj.name}_${selectedDate}.pdf`); doc.save(`frequencia_geral_${classObj.name}.pdf`);
} catch (error) { } catch (error) {
console.error('Error exporting PDF:', error); console.error('Error exporting PDF:', error);
} finally { } finally {

View File

@ -953,13 +953,27 @@ const Students: React.FC<StudentsProps> = ({ data, updateData, deepLinkStudentId
content = content.replace(/{{escola}}/g, data.profile.name || ''); content = content.replace(/{{escola}}/g, data.profile.name || '');
content = content.replace(/{{cnpj_escola}}/g, data.profile.cnpj || ''); content = content.replace(/{{cnpj_escola}}/g, data.profile.cnpj || '');
newContracts.push({ const contractId = crypto.randomUUID();
id: crypto.randomUUID(), const contractPayload = {
id: contractId,
studentId: studentToSave.id, studentId: studentToSave.id,
title: `Contrato - ${course.name}`, title: `Contrato - ${course.name}`,
content, content,
createdAt: new Date().toISOString() createdAt: new Date().toISOString()
};
newContracts.push(contractPayload);
try {
await fetch('/api/contratos', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(contractPayload)
}); });
} catch (contractErr) {
console.error('Erro ao salvar contrato no PostgreSQL:', contractErr);
}
} }
const newData = { const newData = {
@ -1177,10 +1191,19 @@ const Students: React.FC<StudentsProps> = ({ data, updateData, deepLinkStudentId
if (student) { if (student) {
setEditingStudent(student); setEditingStudent(student);
setFormData({ ...defaultData, ...student }); setFormData({
...defaultData,
...student,
generateContract: false,
generateFee: false
});
} else { } else {
setEditingStudent(null); setEditingStudent(null);
setFormData(defaultData); setFormData({
...defaultData,
generateContract: true,
generateFee: false
});
} }
setShowModal(true); setShowModal(true);
}; };
@ -1892,16 +1915,6 @@ const Students: React.FC<StudentsProps> = ({ data, updateData, deepLinkStudentId
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<input
type="checkbox"
id="generateFee"
className="w-4 h-4 text-indigo-600 rounded focus:ring-indigo-500"
checked={(formData as any).generateFee || false}
onChange={e => setFormData({...formData, generateFee: e.target.checked} as any)}
/>
<label htmlFor="generateFee" className="text-sm font-medium text-slate-600">Gerar Taxa de Matrícula</label>
</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input

View File

@ -598,6 +598,12 @@ app.post('/api/portal/frequencia/justificar', authMiddleware, upload.single('arq
if (recordIndex !== -1) { if (recordIndex !== -1) {
const existing = attendance[recordIndex]; const existing = attendance[recordIndex];
if (existing.type === 'presence') return res.status(400).json({ error: 'Não é possível justificar uma presença' }); if (existing.type === 'presence') return res.status(400).json({ error: 'Não é possível justificar uma presença' });
// Regra de segurança: Cada aula só pode ter atestado enviado UMA única vez
if (existing.justification) {
return res.status(400).json({ error: 'Esta aula já possui um atestado/justificativa enviado.' });
}
attendance[recordIndex] = { ...existing, justification: justificationPayload, submittedAt }; attendance[recordIndex] = { ...existing, justification: justificationPayload, submittedAt };
} else { } else {
const newRecord = { const newRecord = {

View File

@ -104,6 +104,17 @@ export default function Frequencia() {
setError(''); setError('');
}; };
const selectedLessonHasJustification = () => {
if (!selectedDate) return false;
const att = attendance.find(a => {
if (!a.date) return false;
const cleanDate = a.date.substring(0, 19);
const cleanSelected = selectedDate.substring(0, 19);
return cleanDate === cleanSelected;
});
return !!(att && att.justification);
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (file) { if (file) {
@ -290,7 +301,6 @@ export default function Frequencia() {
if (att) { if (att) {
if (att.type === 'presence' || (att.verified && att.type !== 'absence')) return false; if (att.type === 'presence' || (att.verified && att.type !== 'absence')) return false;
if (att.justification) return false;
} }
return true; return true;
}); });
@ -817,6 +827,28 @@ export default function Frequencia() {
)} )}
</div> </div>
{selectedLessonHasJustification() && (
<div style={{
background: 'rgba(239, 68, 68, 0.1)',
border: '1px solid rgba(239, 68, 68, 0.3)',
color: 'var(--color-danger)',
padding: '0.85rem',
borderRadius: 10,
fontSize: '0.8125rem',
display: 'flex',
alignItems: 'flex-start',
gap: '0.65rem',
fontWeight: 500,
boxShadow: '0 4px 12px rgba(239, 68, 68, 0.05)',
}}>
<AlertCircle size={18} style={{ flexShrink: 0, marginTop: 1 }} />
<div>
<span style={{ fontWeight: 700, display: 'block', marginBottom: 2 }}>Atenção: Aula Justificada!</span>
Não é possível enviar uma nova justificativa para esta aula, pois ela possui uma justificativa cadastrada.
</div>
</div>
)}
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'flex-end', paddingTop: '0.5rem' }}> <div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'flex-end', paddingTop: '0.5rem' }}>
<button <button
type="button" type="button"
@ -829,7 +861,7 @@ export default function Frequencia() {
<button <button
type="submit" type="submit"
className="btn-primary" className="btn-primary"
disabled={submitLoading} disabled={submitLoading || selectedLessonHasJustification()}
style={{ minWidth: 120, display: 'flex', alignItems: 'center', gap: '0.35rem', justifyContent: 'center' }} style={{ minWidth: 120, display: 'flex', alignItems: 'center', gap: '0.35rem', justifyContent: 'center' }}
> >
{submitLoading ? ( {submitLoading ? (