fix: unify frequency deduplication and fix dashboard crash on portal

This commit is contained in:
Sidney 2026-05-06 09:17:14 -03:00
parent ef8b7c51a8
commit 9ba2e11e27
4 changed files with 31 additions and 12 deletions

View File

@ -52,4 +52,5 @@
23. **Soft Delete Policy (Lixeira)**: Evaluations (exams/activities) MUST NOT be hard-deleted to preserve grade history. Use the `isDeleted` flag to hide them from students and active management views, keeping them accessible in the "Lixeira" for restoration and ensuring reference integrity in the Report Card. 23. **Soft Delete Policy (Lixeira)**: Evaluations (exams/activities) MUST NOT be hard-deleted to preserve grade history. Use the `isDeleted` flag to hide them from students and active management views, keeping them accessible in the "Lixeira" for restoration and ensuring reference integrity in the Report Card.
24. **Grading Arithmetic Logic**: All grade averages (Period, Subject, and General) MUST be calculated as arithmetic means (Average of Means). Summing grades for a final period result is prohibited to ensure consistency between Portal and Manager. 24. **Grading Arithmetic Logic**: All grade averages (Period, Subject, and General) MUST be calculated as arithmetic means (Average of Means). Summing grades for a final period result is prohibited to ensure consistency between Portal and Manager.
25. **SQL Date Handling**: When fetching timestamps from PostgreSQL to be matched with JSON dates/ISO strings in the frontend, ALWAYS use `TO_CHAR(column, 'YYYY-MM-DD"T"HH24:MI:SS')` in the SQL query to prevent Node.js timezone shifts from corrupting string-based matching logic. 25. **SQL Date Handling**: When fetching timestamps from PostgreSQL to be matched with JSON dates/ISO strings in the frontend, ALWAYS use `TO_CHAR(column, 'YYYY-MM-DD"T"HH24:MI:SS')` in the SQL query to prevent Node.js timezone shifts from corrupting string-based matching logic.
26. **SQL-First Architecture Pattern**: New features or module refactors SHOULD prioritize 100% SQL persistence (using `notas_boletim` as a template). The legacy JSON `school_data` should be treated as secondary or deprecated for these modules, ensuring real-time consistency between Manager and Portal.

View File

@ -26,7 +26,12 @@
- [x] **Blindagem de Fuso Horário (Postgres):** Rota de frequência do portal atualizada para usar `TO_CHAR` no SQL, eliminando deslocamentos de horas causados pela conversão UTC automática do driver. - [x] **Blindagem de Fuso Horário (Postgres):** Rota de frequência do portal atualizada para usar `TO_CHAR` no SQL, eliminando deslocamentos de horas causados pela conversão UTC automática do driver.
- [x] **Unificação de Janela de Presença:** Portal e Manager agora utilizam a mesma janela de 30 minutos de tolerância para correlacionar presenças e faltas às aulas. - [x] **Unificação de Janela de Presença:** Portal e Manager agora utilizam a mesma janela de 30 minutos de tolerância para correlacionar presenças e faltas às aulas.
- [x] **Sincronia de Estatísticas (Portal):** O cálculo de porcentagem no Dashboard do Portal agora usa o mesmo motor lógico da página de Frequência, garantindo números idênticos. - [x] **Sincronia de Estatísticas (Portal):** O cálculo de porcentagem no Dashboard do Portal agora usa o mesmo motor lógico da página de Frequência, garantindo números idênticos.
- [ ] Próximo Passo: Expandir a migração relacional para o módulo de "Minhas Aulas" no Portal para manter a consistência arquitetural. - [x] **Consolidação do Modelo Relacional (Notas):** Confirmado que o módulo de Notas/Boletim é o primeiro 100% SQL, servindo de template para futuras migrações. O JSON `school_data.grades` foi oficialmente substituído pela tabela `notas_boletim`.
- [x] **Unificação de Pauta (Deduplicação):** Implementado filtro de deduplicação de aulas no Portal (`Frequencia.tsx` e `Dashboard.tsx`) para ignorar aulas conflitantes, igualando os totais aos do Admin.
- [x] **Regra de Registro Único:** Portal agora exibe apenas a primeira batida válida por aula, eliminando duplicidade visual de biometria.
- [x] **Sincronia de Justificativas:** Ajustada a contagem matemática do Portal para contabilizar faltas justificadas apenas após o aceite do Admin.
- [x] **Fix Dashboard Crash:** Corrigido erro de "tela preta" no Dashboard causado por acesso inseguro a propriedades nulas durante falhas de API.
- [ ] Próximo Passo: Iniciar a migração do módulo Financeiro para 100% SQL seguindo o padrão do Boletim.
## 📅 Histórico Anterior (06/05/2026) ## 📅 Histórico Anterior (06/05/2026)

View File

@ -64,8 +64,8 @@ export default function Dashboard() {
); );
} }
const pendingPayments = data?.payments.filter(p => p.status === 'pending' || p.status === 'overdue') || []; const pendingPayments = data?.payments?.filter(p => p.status === 'pending' || p.status === 'overdue') || [];
const overduePayments = data?.payments.filter(p => p.status === 'overdue') || []; const overduePayments = data?.payments?.filter(p => p.status === 'overdue') || [];
const totalPending = pendingPayments.reduce((s, p) => s + (p.amount - (p.discount || 0)), 0); const totalPending = pendingPayments.reduce((s, p) => s + (p.amount - (p.discount || 0)), 0);
// Synchronized Frequency Calculation (Matches Frequencia.tsx & Manager) // Synchronized Frequency Calculation (Matches Frequencia.tsx & Manager)
@ -73,8 +73,13 @@ export default function Dashboard() {
let validLessonsCount = 0; let validLessonsCount = 0;
if (data?.lessons && data?.attendance) { if (data?.lessons && data?.attendance) {
const nowLocal = new Date(); const deduplicatedLessons = data.lessons.filter((lesson, index, self) =>
data.lessons.forEach(lesson => { index === self.findIndex((t) => (
t.date === lesson.date && t.startTime === lesson.startTime
))
);
deduplicatedLessons.forEach(lesson => {
if (lesson.status === 'cancelled') return; if (lesson.status === 'cancelled') return;
validLessonsCount++; validLessonsCount++;
@ -95,7 +100,7 @@ export default function Dashboard() {
if (isPresent) presencesCount++; if (isPresent) presencesCount++;
}); });
} }
const totalAttendance = data?.attendance.length || 0; const totalAttendance = data?.attendance?.length || 0;
const frequencyPercent = validLessonsCount > 0 ? Math.round((presencesCount / validLessonsCount) * 100) : 0; const frequencyPercent = validLessonsCount > 0 ? Math.round((presencesCount / validLessonsCount) * 100) : 0;
const nextDue = pendingPayments const nextDue = pendingPayments

View File

@ -136,13 +136,21 @@ export default function Frequencia() {
// Deduplicar aulas exatamente como no Manager
const deduplicatedLessons = lessons.filter((lesson, index, self) =>
index === self.findIndex((t) => (
t.date === lesson.date && t.startTime === lesson.startTime
))
);
// Merge and Categorize // Merge and Categorize
const processedItems = lessons.map(lesson => { const processedItems = deduplicatedLessons.map(lesson => {
const lessonFullISO = new Date(parseLessonDateTime(lesson.date, lesson.startTime || '00:00:00')).toISOString(); const lessonFullISO = new Date(parseLessonDateTime(lesson.date, lesson.startTime || '00:00:00')).toISOString();
const lessonStartMs = parseLessonDateTime(lesson.date, lesson.startTime || '00:00:00'); const lessonStartMs = parseLessonDateTime(lesson.date, lesson.startTime || '00:00:00');
const lessonEndMs = parseLessonDateTime(lesson.date, lesson.endTime || '00:00:00', lesson.endTime ? 0 : 60); const lessonEndMs = parseLessonDateTime(lesson.date, lesson.endTime || '00:00:00', lesson.endTime ? 0 : 60);
const atts = attendance.filter(a => { // No Manager, ele procura o primeiro registro válido
const att = attendance.find(a => {
if (!a.date || typeof a.date !== 'string') return false; if (!a.date || typeof a.date !== 'string') return false;
// 1. Exact Match (Including Manager DB fallback format) // 1. Exact Match (Including Manager DB fallback format)
@ -158,7 +166,7 @@ export default function Frequencia() {
const { isInProgress, isCompleted } = getLessonTimeStatus(lesson, now); const { isInProgress, isCompleted } = getLessonTimeStatus(lesson, now);
return { return {
lesson, lesson,
attendances: atts, attendances: att ? [att] : [], // Simulando o comportamento do manager (apenas 1 registro)
isInProgress, isInProgress,
isCompleted isCompleted
}; };
@ -179,14 +187,14 @@ export default function Frequencia() {
if (isPresent) { if (isPresent) {
presences++; presences++;
} else if (hasJustification) { } else if (activeJustification?.justificationAccepted) {
justified++; justified++;
} else if (isCompleted || parseLessonDateTime(lesson.date || '', '23:59:59') < now.getTime()) { } else if (isCompleted || parseLessonDateTime(lesson.date || '', '23:59:59') < now.getTime()) {
absences++; absences++;
} }
}); });
const totalCourseLessons = lessons.filter(l => l.status !== 'cancelled').length; const totalCourseLessons = deduplicatedLessons.filter(l => l.status !== 'cancelled').length;
const completedLessons = processedItems.filter(item => item.isCompleted && item.lesson.status !== 'cancelled').length; const completedLessons = processedItems.filter(item => item.isCompleted && item.lesson.status !== 'cancelled').length;
const pendingLessons = processedItems.filter(item => !item.isCompleted && item.lesson.status !== 'cancelled').length; const pendingLessons = processedItems.filter(item => !item.isCompleted && item.lesson.status !== 'cancelled').length;
const percentage = totalCourseLessons > 0 ? Math.round((presences / totalCourseLessons) * 100) : 0; const percentage = totalCourseLessons > 0 ? Math.round((presences / totalCourseLessons) * 100) : 0;
@ -206,7 +214,7 @@ export default function Frequencia() {
const displayItems = activeTab === 'scheduled' ? activeItems : historyItems; const displayItems = activeTab === 'scheduled' ? activeItems : historyItems;
// Collect lessons available for justification modal dropdown // Collect lessons available for justification modal dropdown
const justifiableLessons = lessons.filter(l => { const justifiableLessons = deduplicatedLessons.filter(l => {
if (l.status === 'cancelled') return false; if (l.status === 'cancelled') return false;
// Check window (uses new 24h before/after logic) // Check window (uses new 24h before/after logic)