fix(attendance): complete SQL-First migration for frequencias and fix portal justification logic
This commit is contained in:
parent
d4b73df9b4
commit
3fe0d964a5
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
// ============================================================
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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 schoolData = await getSchoolData();
|
||||
const attendance = (schoolData.attendance || []).filter(a => a.studentId === req.user.studentId);
|
||||
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 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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue