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);
|
||||
|
||||
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.text(`Data: ${new Date(selectedDate).toLocaleDateString()}`, 14, startY + 18);
|
||||
doc.text(`Turma: ${classObj.name}`, 14, startY + 24);
|
||||
doc.text(`Turma: ${classObj.name}`, 14, startY + 18);
|
||||
doc.text(`Data de Emissao: ${new Date().toLocaleDateString('pt-BR')}`, 14, startY + 24);
|
||||
|
||||
const classAttendance = dbAttendance.filter(record =>
|
||||
record.classId === classObj.id && record.date.startsWith(selectedDate)
|
||||
const classStudents = data.students
|
||||
.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 => {
|
||||
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) { }
|
||||
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++;
|
||||
}
|
||||
});
|
||||
|
||||
const totalWithJustified = presences + absences + justified;
|
||||
const attendanceRate = totalWithJustified > 0 ? ((presences + justified) / totalWithJustified) * 100 : 100;
|
||||
|
||||
return [
|
||||
student?.name || 'Desconhecido',
|
||||
time,
|
||||
record.type === 'absence' ? (record.justificationAccepted ? 'Falta Justificada' : 'Falta') : 'Presente',
|
||||
justMotivo
|
||||
student.name,
|
||||
student.enrollmentNumber || '—',
|
||||
`${presences} P`,
|
||||
`${absences} F`,
|
||||
`${justified} J`,
|
||||
`${attendanceRate.toFixed(0)}%`
|
||||
];
|
||||
});
|
||||
|
||||
(doc as any).autoTable({
|
||||
startY: startY + 30,
|
||||
head: [['Aluno', 'Horário', 'Status', 'Justificativa']],
|
||||
head: [['Aluno', 'Matricula', 'Presencas', 'Faltas', 'Justificadas', 'Frequencia %']],
|
||||
body: tableData,
|
||||
});
|
||||
|
||||
doc.save(`frequencia_${classObj.name}_${selectedDate}.pdf`);
|
||||
doc.save(`frequencia_geral_${classObj.name}.pdf`);
|
||||
} catch (error) {
|
||||
console.error('Error exporting PDF:', error);
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -953,13 +953,27 @@ const Students: React.FC<StudentsProps> = ({ data, updateData, deepLinkStudentId
|
|||
content = content.replace(/{{escola}}/g, data.profile.name || '');
|
||||
content = content.replace(/{{cnpj_escola}}/g, data.profile.cnpj || '');
|
||||
|
||||
newContracts.push({
|
||||
id: crypto.randomUUID(),
|
||||
const contractId = crypto.randomUUID();
|
||||
const contractPayload = {
|
||||
id: contractId,
|
||||
studentId: studentToSave.id,
|
||||
title: `Contrato - ${course.name}`,
|
||||
content,
|
||||
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 = {
|
||||
|
|
@ -1177,10 +1191,19 @@ const Students: React.FC<StudentsProps> = ({ data, updateData, deepLinkStudentId
|
|||
|
||||
if (student) {
|
||||
setEditingStudent(student);
|
||||
setFormData({ ...defaultData, ...student });
|
||||
setFormData({
|
||||
...defaultData,
|
||||
...student,
|
||||
generateContract: false,
|
||||
generateFee: false
|
||||
});
|
||||
} else {
|
||||
setEditingStudent(null);
|
||||
setFormData(defaultData);
|
||||
setFormData({
|
||||
...defaultData,
|
||||
generateContract: true,
|
||||
generateFee: false
|
||||
});
|
||||
}
|
||||
setShowModal(true);
|
||||
};
|
||||
|
|
@ -1892,16 +1915,6 @@ const Students: React.FC<StudentsProps> = ({ data, updateData, deepLinkStudentId
|
|||
</div>
|
||||
|
||||
<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">
|
||||
<input
|
||||
|
|
|
|||
|
|
@ -598,6 +598,12 @@ app.post('/api/portal/frequencia/justificar', authMiddleware, upload.single('arq
|
|||
if (recordIndex !== -1) {
|
||||
const existing = attendance[recordIndex];
|
||||
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 };
|
||||
} else {
|
||||
const newRecord = {
|
||||
|
|
|
|||
|
|
@ -104,6 +104,17 @@ export default function Frequencia() {
|
|||
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 file = e.target.files?.[0];
|
||||
if (file) {
|
||||
|
|
@ -290,7 +301,6 @@ export default function Frequencia() {
|
|||
|
||||
if (att) {
|
||||
if (att.type === 'presence' || (att.verified && att.type !== 'absence')) return false;
|
||||
if (att.justification) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
|
@ -817,6 +827,28 @@ export default function Frequencia() {
|
|||
)}
|
||||
</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' }}>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -829,7 +861,7 @@ export default function Frequencia() {
|
|||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={submitLoading}
|
||||
disabled={submitLoading || selectedLessonHasJustification()}
|
||||
style={{ minWidth: 120, display: 'flex', alignItems: 'center', gap: '0.35rem', justifyContent: 'center' }}
|
||||
>
|
||||
{submitLoading ? (
|
||||
|
|
|
|||
Loading…
Reference in New Issue