diff --git a/GEMINI.md b/GEMINI.md index b52e706..91e84aa 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -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. 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. +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. diff --git a/MEMORY.md b/MEMORY.md index c40e29c..61ee68d 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -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] **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. -- [ ] 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) diff --git a/portal/src/pages/Dashboard.tsx b/portal/src/pages/Dashboard.tsx index feed562..9a5f38b 100644 --- a/portal/src/pages/Dashboard.tsx +++ b/portal/src/pages/Dashboard.tsx @@ -64,8 +64,8 @@ export default function Dashboard() { ); } - const pendingPayments = data?.payments.filter(p => p.status === 'pending' || p.status === 'overdue') || []; - const overduePayments = data?.payments.filter(p => 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 totalPending = pendingPayments.reduce((s, p) => s + (p.amount - (p.discount || 0)), 0); // Synchronized Frequency Calculation (Matches Frequencia.tsx & Manager) @@ -73,8 +73,13 @@ export default function Dashboard() { let validLessonsCount = 0; if (data?.lessons && data?.attendance) { - const nowLocal = new Date(); - data.lessons.forEach(lesson => { + const deduplicatedLessons = data.lessons.filter((lesson, index, self) => + index === self.findIndex((t) => ( + t.date === lesson.date && t.startTime === lesson.startTime + )) + ); + + deduplicatedLessons.forEach(lesson => { if (lesson.status === 'cancelled') return; validLessonsCount++; @@ -95,7 +100,7 @@ export default function Dashboard() { 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 nextDue = pendingPayments diff --git a/portal/src/pages/Frequencia.tsx b/portal/src/pages/Frequencia.tsx index 7283736..9e911dd 100644 --- a/portal/src/pages/Frequencia.tsx +++ b/portal/src/pages/Frequencia.tsx @@ -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 - const processedItems = lessons.map(lesson => { + const processedItems = deduplicatedLessons.map(lesson => { const lessonFullISO = new Date(parseLessonDateTime(lesson.date, lesson.startTime || '00:00:00')).toISOString(); 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 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; // 1. Exact Match (Including Manager DB fallback format) @@ -158,7 +166,7 @@ export default function Frequencia() { const { isInProgress, isCompleted } = getLessonTimeStatus(lesson, now); return { lesson, - attendances: atts, + attendances: att ? [att] : [], // Simulando o comportamento do manager (apenas 1 registro) isInProgress, isCompleted }; @@ -179,14 +187,14 @@ export default function Frequencia() { if (isPresent) { presences++; - } else if (hasJustification) { + } else if (activeJustification?.justificationAccepted) { justified++; } else if (isCompleted || parseLessonDateTime(lesson.date || '', '23:59:59') < now.getTime()) { 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 pendingLessons = processedItems.filter(item => !item.isCompleted && item.lesson.status !== 'cancelled').length; const percentage = totalCourseLessons > 0 ? Math.round((presences / totalCourseLessons) * 100) : 0; @@ -206,7 +214,7 @@ export default function Frequencia() { const displayItems = activeTab === 'scheduled' ? activeItems : historyItems; // Collect lessons available for justification modal dropdown - const justifiableLessons = lessons.filter(l => { + const justifiableLessons = deduplicatedLessons.filter(l => { if (l.status === 'cancelled') return false; // Check window (uses new 24h before/after logic)