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
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 2m17s
Details
This commit is contained in:
parent
3d683ac68a
commit
4a922f43ec
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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 Já Justificada!</span>
|
||||||
|
Não é possível enviar uma nova justificativa para esta aula, pois ela já 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 ? (
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue