fix(attendance): complete SQL-First migration for frequencias and fix portal justification logic

This commit is contained in:
Sidney 2026-05-25 08:12:08 -03:00
parent d4b73df9b4
commit 3fe0d964a5
7 changed files with 191 additions and 39 deletions

View File

@ -147,6 +147,9 @@ const AdminNotifications: React.FC<Props> = ({ data, updateData, setView, onNavi
a.id === matchedAbsence.id ? { ...a, justificationAccepted: true } : a
);
const modifiedRecord = updatedAttendance.find(a => a.id === matchedAbsence.id);
fetch(`/api/frequencias/${matchedAbsence.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(modifiedRecord) });
updateData({ attendance: updatedAttendance });
dbService.saveData({ ...data, attendance: updatedAttendance });
handleMarkAsRead(notif.id);

View File

@ -280,14 +280,15 @@ const AttendanceCapture: React.FC<AttendanceCaptureProps> = ({ data, updateData
verified: true
};
fetch('/api/frequencias', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newAttendance)
});
const updatedAttendance = [...filteredAttendance, newAttendance];
updateData({ attendance: updatedAttendance });
// Sincronização em duas etapas: Local e Servidor (SQL)
const updatedData = { ...data, attendance: updatedAttendance };
dbService.saveData(updatedData);
dbService.saveToCloud(updatedData);
// Reset de interface
setCapturedImage(null);
setShowConfirmModal(false);

View File

@ -106,21 +106,22 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
if (existingIdx >= 0) {
updatedAttendance[existingIdx] = { ...updatedAttendance[existingIdx], type: newType, justification: undefined, justificationAccepted: undefined };
fetch(`/api/frequencias/${updatedAttendance[existingIdx].id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updatedAttendance[existingIdx]) });
} else {
updatedAttendance.push(newRecord);
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 ? { ...a, type: newType, justification: undefined, justificationAccepted: undefined } : a
a.id === record.id ? modifiedRecord : a
);
fetch(`/api/frequencias/${record.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(modifiedRecord) });
}
updateData({ attendance: updatedAttendance });
const updatedData = { ...data, attendance: updatedAttendance };
dbService.saveData(updatedData);
dbService.saveToCloud(updatedData); // Sincronia imediata com SQL
showAlert('Sucesso', 'Status de frequência atualizado com sucesso.', 'success');
};
@ -137,8 +138,10 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
a.id === attendanceForAttachment.id ? { ...a, justification: updatedJustification } : a
);
const modifiedRecord = updatedAttendance.find(a => a.id === attendanceForAttachment.id);
fetch(`/api/frequencias/${attendanceForAttachment.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(modifiedRecord) });
updateData({ attendance: updatedAttendance });
dbService.saveData({ ...data, attendance: updatedAttendance });
setViewingAttachment(null);
setAttendanceForAttachment(null);
showAlert('Sucesso', 'Arquivo removido com sucesso.', 'success');
@ -203,6 +206,7 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
verified: true,
lessonId: lesson.id as any
};
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(),
@ -216,12 +220,11 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
...(lesson ? { lessonId: lesson.id } : {}) as any
};
updatedAttendance.push(newAbsence);
fetch('/api/frequencias', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(newAbsence) });
}
const updatedData = { ...data, attendance: updatedAttendance };
updateData({ attendance: updatedAttendance });
dbService.saveData(updatedData);
dbService.saveToCloud(updatedData); // Sincronia imediata com SQL
dbService.saveData({ ...data, attendance: updatedAttendance });
setAbsenceStudentId('');
setAbsenceJustification('');
@ -744,6 +747,8 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
<button
onClick={() => {
const updated = (data.attendance || []).map(a => a.id === record.id ? { ...a, justificationAccepted: true } : a);
const modifiedRecord = updated.find(a => a.id === record.id);
fetch(`/api/frequencias/${record.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');
@ -936,6 +941,8 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
<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');

View File

@ -43,6 +43,7 @@ import {
getModelosContrato, insertModeloContrato, updateModeloContrato, deleteModeloContrato,
getContratos, insertContrato, updateContrato, deleteContrato,
getAulasByTurma, getAllAulas, insertAulas, deleteAulas,
getFrequencias, insertFrequencia, updateFrequencia, deleteFrequencia,
getProvas, getQuestoesDaProva, insertProva, updateProva, deleteProva, syncQuestoesProva
} from './services/database.js';
import { uploadLogo as uploadLogoToStorage, uploadCarne as uploadCarneToStorage, uploadReceipt as uploadReceiptToStorage, getMinioStats, s3Client, getBucketObjects, deleteMinioObject } from './services/storage.js';
@ -145,6 +146,13 @@ app.get('/api/school-data', async (req, res) => {
try {
const data = await getSchoolData();
// Injetar dados migrados diretamente do PostgreSQL
try {
data.attendance = await getFrequencias();
} catch (e) {
console.error('[SQL] Falha ao carregar frequencias do banco:', e);
}
// Normalizar URLs do MinIO para proxy relativo
// Converte URLs como https://storageedu.xxx/bucket/file para /storage/bucket/file
const MINIO_PUBLIC_URL = process.env.MINIO_PUBLIC_URL || '';
@ -589,6 +597,49 @@ app.delete('/api/aulas/lote', async (req, res) => {
}
});
// ============================================================
// ROTAS DE FREQUÊNCIAS (CHAMADA)
// ============================================================
app.get('/api/frequencias', async (req, res) => {
try {
const frequencias = await getFrequencias();
res.json({ frequencias });
} catch (error) {
console.error('Erro ao buscar frequencias:', error);
res.status(500).json({ error: 'Erro interno' });
}
});
app.post('/api/frequencias', async (req, res) => {
try {
await insertFrequencia(req.body);
res.json({ success: true });
} catch (error) {
console.error('Erro ao inserir frequencia:', error);
res.status(500).json({ error: 'Erro interno' });
}
});
app.put('/api/frequencias/:id', async (req, res) => {
try {
await updateFrequencia(req.params.id, req.body);
res.json({ success: true });
} catch (error) {
console.error('Erro ao atualizar frequencia:', error);
res.status(500).json({ error: 'Erro interno' });
}
});
app.delete('/api/frequencias/:id', async (req, res) => {
try {
await deleteFrequencia(req.params.id);
res.json({ success: true });
} catch (error) {
console.error('Erro ao deletar frequencia:', error);
res.status(500).json({ error: 'Erro interno' });
}
});
// ============================================================
// ROTAS DE AVALIAÇÕES (MIGRAÇÃO FASE 5)
// ============================================================

View File

@ -797,6 +797,51 @@ export async function deleteAulas(ids) {
await pool.query('DELETE FROM aulas WHERE id = ANY($1)', [ids]);
}
// ============================================================
// FREQUÊNCIAS (CHAMADA)
// ============================================================
export async function getFrequencias() {
const { rows } = await pool.query('SELECT * FROM frequencias ORDER BY created_at DESC');
return rows.map(r => ({
id: r.id,
studentId: r.aluno_id,
classId: r.turma_id,
lessonId: r.aula_id,
date: r.data,
photo: r.foto,
verified: r.verificado,
type: r.tipo,
justification: r.justificativa,
justificationAccepted: r.justificativa_aceita,
createdAt: r.created_at
}));
}
export async function insertFrequencia(f) {
await pool.query(
`INSERT INTO frequencias (id, aluno_id, turma_id, aula_id, data, foto, verificado, tipo, justificativa, justificativa_aceita)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
[
f.id, f.studentId, f.classId, f.lessonId, f.date, f.photo,
f.verified || false, f.type || 'presence', f.justification, f.justificationAccepted || false
]
);
}
export async function updateFrequencia(id, f) {
// Update apenas dos campos que podem mudar
await pool.query(
`UPDATE frequencias
SET tipo = $1, justificativa = $2, justificativa_aceita = $3, verificado = $4
WHERE id = $5`,
[f.type, f.justification, f.justificationAccepted, f.verified, id]
);
}
export async function deleteFrequencia(id) {
await pool.query('DELETE FROM frequencias WHERE id = $1', [id]);
}
// ============================================================
// PROVAS & QUESTÕES (FASE 5)
// ============================================================
@ -1101,27 +1146,8 @@ export async function syncJsonToRelationalTables() {
}
// 7. Sincronizar Frequências
if (data.attendance && Array.isArray(data.attendance)) {
const attIds = data.attendance.map(f => f.id).filter(Boolean);
if (attIds.length > 0) {
await client.query('DELETE FROM frequencias WHERE id != ALL($1)', [attIds]);
} else {
await client.query('DELETE FROM frequencias');
}
for (const f of data.attendance) {
if (!f.id || !f.studentId || !f.classId) continue;
await client.query(
`INSERT INTO frequencias (id, aluno_id, turma_id, aula_id, data, foto, verificado, tipo, justificativa, justificativa_aceita)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (id) DO UPDATE SET
aluno_id = EXCLUDED.aluno_id, turma_id = EXCLUDED.turma_id, aula_id = EXCLUDED.aula_id, data = EXCLUDED.data,
foto = EXCLUDED.foto, verificado = EXCLUDED.verificado, tipo = EXCLUDED.tipo,
justificativa = EXCLUDED.justificativa, justificativa_aceita = EXCLUDED.justificativa_aceita`,
[f.id, f.studentId, f.classId, f.lessonId || null, f.date, f.photo || '', f.verified || false, f.type || 'presence', f.justification || null, f.justificationAccepted || false]
);
}
}
// [REMOVIDO] A tabela de frequencias agora é a Single Source of Truth (SQL-First).
// O JSON legado data.attendance é ignorado para não sobrescrever os registros reais do banco.
// 8. Sincronizar Cobranças (Financeiro) — com campos ricos para migração SQL-First
if (data.payments && Array.isArray(data.payments)) {

View File

@ -508,11 +508,35 @@ app.get('/api/portal/notas', authMiddleware, async (req, res) => {
}
});
// GET /api/portal/frequencia (Leitura direta do school_data — mesma fonte do Manager)
// GET /api/portal/frequencia (SQL-First — Leitura direta do PostgreSQL)
app.get('/api/portal/frequencia', authMiddleware, async (req, res) => {
try {
const { rows } = await pool.query(
`SELECT * FROM frequencias WHERE aluno_id = $1 ORDER BY data DESC`,
[req.user.studentId]
);
const attendance = rows.map(r => ({
id: r.id,
studentId: r.aluno_id,
classId: r.turma_id,
lessonId: r.aula_id,
date: r.data,
photo: r.foto_url || r.foto,
verified: r.verificado,
type: r.tipo,
justification: r.justificativa,
justificationAccepted: r.justificativa_aceita,
createdAt: r.created_at
}));
// Fallback Híbrido: Se não achou no SQL, tenta pegar do JSON (caso haja registros antigos não sincronizados)
if (attendance.length === 0) {
const schoolData = await getSchoolData();
const attendance = (schoolData.attendance || []).filter(a => a.studentId === req.user.studentId);
const fallbackAttendance = (schoolData.attendance || []).filter(a => a.studentId === req.user.studentId);
return res.json({ attendance: fallbackAttendance });
}
res.json({ attendance });
} catch (err) {
console.error('Frequencia error:', err);

View File

@ -49,7 +49,47 @@ export default function Frequencia() {
const openJustifyModal = (preselectedTimestamp?: string) => {
setShowJustifyModal(true);
setSelectedDate(preselectedTimestamp || '');
let initialDate = preselectedTimestamp || '';
if (!initialDate) {
// Find the closest justifiable lesson
const deduplicated = lessons.filter((lesson, index, self) =>
index === self.findIndex((t) => t.date === lesson.date && t.startTime === lesson.startTime)
);
const justifiable = deduplicated.filter(l => {
if (l.status === 'cancelled') return false;
if (!isLessonWithinJustificationWindow(l, now)) return false;
const lessonStartMs = parseLessonDateTime(l.date, l.startTime || '00:00', 0);
const lessonEndMs = parseLessonDateTime(l.date, l.endTime || '23:59', 23);
const presenceStartWindowMs = lessonStartMs - (30 * 60 * 1000);
const att = attendance.find(a => {
if (!a.date || typeof a.date !== 'string') return false;
if ((a as any).lessonId === l.id) return true;
const recordTime = new Date(a.date).getTime();
return recordTime >= presenceStartWindowMs && recordTime <= lessonEndMs;
});
if (att) {
if (att.type === 'presence' || (att.verified && att.type !== 'absence')) return false;
if (att.justification) return false;
}
return true;
});
if (justifiable.length > 0) {
const closest = justifiable.sort((a, b) => {
const diffA = Math.abs(now.getTime() - parseLessonDateTime(a.date, a.startTime));
const diffB = Math.abs(now.getTime() - parseLessonDateTime(b.date, b.startTime));
return diffA - diffB;
})[0];
initialDate = `${closest.date}T${closest.startTime || '00:00'}:00`;
}
}
setSelectedDate(initialDate);
setJustificationText('');
setJustificationFile(null);
setError('');
@ -249,7 +289,7 @@ export default function Frequencia() {
});
if (att) {
if (att.type === 'presence' || att.verified) return false;
if (att.type === 'presence' || (att.verified && att.type !== 'absence')) return false;
if (att.justification) return false;
}
return true;