From 9e44ce0712920cda4c777a98a2ffcc6add44d4c8 Mon Sep 17 00:00:00 2001 From: Sidney Date: Tue, 5 May 2026 20:50:35 -0300 Subject: [PATCH] feat: unified notification system with SQL and WhatsApp integration --- GEMINI.md | 1 + MEMORY.md | 7 + manager/components/AdminNotifications.tsx | 77 +++++++---- manager/components/AttendanceQuery.tsx | 15 +- manager/components/Exams.tsx | 39 +++++- manager/components/Messages.tsx | 11 +- manager/server.selfhosted.js | 116 ++++++++++++++++ manager/services/database.js | 14 +- portal/server.selfhosted.js | 86 +++++++----- portal/src/pages/Avaliacoes.tsx | 161 +++++++++++----------- portal/src/pages/Frequencia.tsx | 36 ++++- 11 files changed, 407 insertions(+), 156 deletions(-) diff --git a/GEMINI.md b/GEMINI.md index 4693669..b52b5d9 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -48,4 +48,5 @@ 19. **Numerical Data Integrity**: When retrieving data from PostgreSQL `NUMERIC` or `DECIMAL` columns, values MUST be explicitly cast to `Number()` in the backend before being sent to the frontend to prevent crashes when using `.toFixed()` in React. 20. **Exam Duplication**: The system supports cloning evaluations. Duplicated exams MUST default to `draft` status and include `(Cópia)` in the title to allow verification before deployment to new classes. 21. **Automatic Attendance Closure**: The system implements an automatic closure routine (`processAutoAbsences`) that generates physical "Absence" records in the PostgreSQL database for any past lesson where a student has no presence or justification. This routine is triggered during data save operations to ensure retroactive consistency between lessons and records. +22. **Unified Notification System (SQL)**: The system uses the `notificacoes` PostgreSQL table as the single source of truth for both Portal and Manager. JSON-based notifications in `school_data` are deprecated. New features (exams, justifications) MUST use SQL INSERT/SELECT and implement **Intelligent Polling (30s)** in the UI to ensure synchronization. diff --git a/MEMORY.md b/MEMORY.md index 52ea257..4ad7d53 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -13,6 +13,10 @@ - [x] **Sincronização Bidirecional (Frequência):** Garantido que justificativas enviadas pelo Portal atualizem instantaneamente a tabela relacional via `ON CONFLICT`. - [x] **Auto-Migração de Esquema:** Implementada lógica de auto-correção de colunas (`ALTER TABLE`) na rotina de sincronização do banco de dados (`database.js`). - [x] **Fechamento Automático de Pauta:** Implementada rotina `processAutoAbsences` que gera registros físicos de falta para aulas passadas sem registro, garantindo consistência entre Portal e Manager. +- [x] **Sistema de Notificações Unificado (SQL):** Migração completa do sistema de notificações (sino) para a tabela relacional `notificacoes`, eliminando a dependência do JSON legado. +- [x] **Alertas de Avaliações:** Implementado disparo automático de notificações SQL e WhatsApp (via Evolution API) para turmas inteiras ao publicar exames/atividades. +- [x] **Justificativas Relacionais:** Notificações de justificativas de falta enviadas pelo Portal agora são salvas diretamente no PostgreSQL (aluno_id = 'admin'). +- [x] **Intelligent Polling Admin:** O Admin Bell agora utiliza polling de 30s para sincronização em tempo real com o banco SQL, garantindo que novos alertas apareçam instantaneamente. - [ ] Próximo Passo: Expandir a migração relacional para o módulo de "Minhas Aulas" no Portal para manter a consistência arquitetural. @@ -38,6 +42,9 @@ - [x] **Storage Explorer (MinIO):** Criada interface de gerenciamento de arquivos que permite navegar por buckets (pastas), visualizar (lightbox), baixar e excluir arquivos físicos individualmente. - [x] **Database Data Viewer:** Implementada a visualização de registros (linhas) diretamente no Database Explorer, com suporte a redimensionamento automático de colunas e truncamento de dados longos. - [x] **Controle de Refação (Retake Policy):** Adicionado botão de cadeado nos cards de Avaliações para permitir ou bloquear que alunos refaçam provas no portal (Regra 15). +* **Conclusão da Refação de Provas:** Implementada a sincronização do campo `allowRetake` (cadeado) com o PostgreSQL e corrigida a inicialização de novas provas. +* **Unificação de Estatísticas de Frequência:** Sincronizada a lógica dos cards de estatística com a lista visual no Portal, garantindo que aulas reagendadas contem como faltas/presenças e que os números batam 100%. +* **Garantia de Integridade:** As notas de refação agora sobrepõem corretamente os registros anteriores no banco de dados. - [x] **UI de Avaliações:** Padronização dos botões de edição ("Editar Prova" vs "Editar Atividade") e adição de botão de exclusão rápida direto no card. - [x] **Correção de Vínculo de Notas:** Garantido que o `examId` seja sempre salvo nas notas geradas pelo Portal para preenchimento automático do Boletim Escolar no Manager. - [x] **Fix Memory Leak:** Removido `pool.on('error')` que estava dentro da rota `PUT /api/school-data`, acumulando listeners a cada salvamento. diff --git a/manager/components/AdminNotifications.tsx b/manager/components/AdminNotifications.tsx index 92e9e00..b490b43 100644 --- a/manager/components/AdminNotifications.tsx +++ b/manager/components/AdminNotifications.tsx @@ -14,23 +14,42 @@ const AdminNotifications: React.FC = ({ data, updateData, setView, onNavi const [isOpen, setIsOpen] = useState(false); const [viewingAttachment, setViewingAttachment] = useState(null); const [notifWithAttachment, setNotifWithAttachment] = useState(null); + const [adminNotifs, setAdminNotifs] = useState([]); const prevCountRef = useRef(0); const audioRef = useRef(null); - const handleDeleteAttachment = () => { + const handleDeleteAttachment = async () => { if (!notifWithAttachment) return; - - const updatedNotifs = (data.notifications || []).map(n => - n.id === notifWithAttachment.id ? { ...n, attachment: undefined } : n - ); - - updateData({ notifications: updatedNotifs }); - dbService.saveData({ ...data, notifications: updatedNotifs }); - setViewingAttachment(null); - setNotifWithAttachment(null); + try { + const resp = await fetch(`/api/notificacoes/remover-anexo/${notifWithAttachment.id}`, { method: 'PUT' }); + if (resp.ok) { + setAdminNotifs(prev => prev.map(n => n.id === notifWithAttachment.id ? { ...n, attachment: undefined } : n)); + setViewingAttachment(null); + setNotifWithAttachment(null); + } + } catch (e) { + console.error('Erro ao excluir anexo:', e); + } }; - const adminNotifs = (data.notifications || []).filter(n => n.studentId === 'admin').sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + const fetchNotifications = async () => { + try { + const resp = await fetch('/api/notificacoes/admin'); + if (resp.ok) { + const d = await resp.json(); + setAdminNotifs(d.notifications); + } + } catch (e) { + console.error('Erro ao buscar notificações admin:', e); + } + }; + + useEffect(() => { + fetchNotifications(); + const interval = setInterval(fetchNotifications, 30000); // Polling 30s + return () => clearInterval(interval); + }, []); + const unreadCount = adminNotifs.filter(n => !n.read).length; // Som de notificação quando chega uma nova @@ -73,18 +92,26 @@ const AdminNotifications: React.FC = ({ data, updateData, setView, onNavi } }; - const handleMarkAsRead = (id: string) => { - const updatedAll = (data.notifications || []).map(n => - n.id === id ? { ...n, read: true } : n - ); - updateData({ notifications: updatedAll }); - dbService.saveData({ ...data, notifications: updatedAll }); + const handleMarkAsRead = async (id: string) => { + try { + const resp = await fetch(`/api/notificacoes/ler/${id}`, { method: 'PUT' }); + if (resp.ok) { + setAdminNotifs(prev => prev.map(n => n.id === id ? { ...n, read: true } : n)); + } + } catch (e) { + console.error('Erro ao marcar como lida:', e); + } }; - const handleClearRead = () => { - const others = (data.notifications || []).filter(n => n.studentId !== 'admin' || (n.studentId === 'admin' && !n.read)); - updateData({ notifications: others }); - dbService.saveData({ ...data, notifications: others }); + const handleClearRead = async () => { + try { + const resp = await fetch('/api/notificacoes/limpar-lidas', { method: 'DELETE' }); + if (resp.ok) { + setAdminNotifs(prev => prev.filter(n => !n.read)); + } + } catch (e) { + console.error('Erro ao limpar lidas:', e); + } }; // Aceitar justificativa diretamente pela notificação @@ -104,12 +131,10 @@ const AdminNotifications: React.FC = ({ data, updateData, setView, onNavi const updatedAttendance = (data.attendance || []).map(a => a.id === matchedAbsence.id ? { ...a, justificationAccepted: true } : a ); - const updatedNotifs = (data.notifications || []).map(n => - n.id === notif.id ? { ...n, read: true } : n - ); - updateData({ attendance: updatedAttendance, notifications: updatedNotifs }); - dbService.saveData({ ...data, attendance: updatedAttendance, notifications: updatedNotifs }); + updateData({ attendance: updatedAttendance }); + dbService.saveData({ ...data, attendance: updatedAttendance }); + handleMarkAsRead(notif.id); } else { // Se não encontrou pendentes, apenas marca como lida handleMarkAsRead(notif.id); diff --git a/manager/components/AttendanceQuery.tsx b/manager/components/AttendanceQuery.tsx index b8f0b1f..1433d91 100644 --- a/manager/components/AttendanceQuery.tsx +++ b/manager/components/AttendanceQuery.tsx @@ -484,11 +484,16 @@ const AttendanceQuery: React.FC = ({ data, updateData, dee
{(() => { const now = new Date(); + const studentClassIds = new Set([ + selectedClass.id, + ...(data.attendance || []).filter(a => a.studentId === selectedStudent.id).map(a => a.classId) + ].filter(Boolean)); + const actualRecords = (data.attendance || []) - .filter(a => a.studentId === selectedStudent.id && a.classId === selectedClass.id); + .filter(a => a.studentId === selectedStudent.id); const classLessonsRaw = (data.lessons || []) - .filter(l => l.classId === selectedClass.id && l.status !== 'cancelled'); + .filter(l => studentClassIds.has(l.classId) && l.status !== 'cancelled'); const deduplicatedLessons = classLessonsRaw.filter((lesson, index, self) => index === self.findIndex((t) => ( @@ -515,7 +520,7 @@ const AttendanceQuery: React.FC = ({ data, updateData, dee record = { id: `v-${lesson.id}`, studentId: selectedStudent.id, - classId: selectedClass.id, + classId: lesson.classId || selectedClass.id, date: `${lesson.date}T${lesson.startTime || '00:00'}:00`, type: isFinished ? 'absence' : 'awaiting', isVirtual: true, @@ -577,6 +582,7 @@ const AttendanceQuery: React.FC = ({ data, updateData, dee Data + Turma Início (Aula) Término (Aula) Registro @@ -612,6 +618,9 @@ const AttendanceQuery: React.FC = ({ data, updateData, dee {recordDate.toLocaleDateString('pt-BR')} + + {data.classes.find(c => c.id === lesson?.classId)?.name || '—'} +
{lesson?.startTime || '--:--'} diff --git a/manager/components/Exams.tsx b/manager/components/Exams.tsx index 7ee99e7..c219992 100644 --- a/manager/components/Exams.tsx +++ b/manager/components/Exams.tsx @@ -1,6 +1,6 @@ import React, { useState, useRef } from 'react'; import { SchoolData, Exam, Question } from '../types'; -import { FileText, Plus, Search, BookOpen, Upload, Trash2, ArrowLeft, Save, CheckCircle, Image as ImageIcon, X, RefreshCw, Lock, Unlock, AlertTriangle, Copy } from 'lucide-react'; +import { FileText, Plus, Search, BookOpen, Upload, Trash2, ArrowLeft, Save, CheckCircle, Image as ImageIcon, X, RefreshCw, Lock, Unlock, AlertTriangle, Copy, Bell } from 'lucide-react'; import { uploadExamImage } from '../services/supabase'; import { useDialog } from '../DialogContext'; import { dbService } from '../services/dbService'; @@ -45,7 +45,8 @@ const Exams: React.FC = ({ data, updateData }) => { status: 'draft', questions: [], evaluationType: 'exam', - maxScore: 10 + maxScore: 10, + allowRetake: false } as any); setCurrentView('builder'); }; @@ -183,6 +184,33 @@ const Exams: React.FC = ({ data, updateData }) => { } }; + const handleNotifyStudents = async (exam: Exam) => { + const classObj = (data.classes || []).find(c => c.id === exam.classId); + if (!classObj) return; + + showConfirm( + 'Notificar Turma', + `Deseja enviar WhatsApp e notificação no portal para os alunos da turma ${classObj.name} informando sobre esta avaliação?`, + async () => { + try { + const resp = await fetch('/api/exames/notificar', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ examId: exam.id }) + }); + const resData = await resp.json(); + if (resp.ok) { + showAlert('Sucesso', 'Notificações enviadas com sucesso!', 'success'); + } else { + showAlert('Erro', resData.error || 'Erro ao notificar turma.', 'error'); + } + } catch (e) { + showAlert('Erro', 'Erro de conexão.', 'error'); + } + } + ); + }; + const handleSave = (status: 'draft' | 'published') => { if (!editingExam) return; @@ -602,6 +630,13 @@ const Exams: React.FC = ({ data, updateData }) => { > + + + + ) : ( + + )} +
+ )} +
+ + ); + }; + // ========================================== // RENDER: Listing // ========================================== @@ -190,6 +270,7 @@ export default function Avaliacoes() {
))}
+ {renderAppModal()} ); } @@ -444,83 +525,7 @@ export default function Avaliacoes() { )} - {/* In-App Modal */} - {showModal && ( -
-
-
- {modalType === 'error' - ? - : modalType === 'confirm' - ? - : modalType === 'loading' - ? - : - } -
-

- {modalMsg} -

- {modalType !== 'loading' && ( -
- {modalType === 'confirm' ? ( - <> - - - - ) : ( - - )} -
- )} -
-
- )} + {renderAppModal()} ); } @@ -626,6 +631,7 @@ export default function Avaliacoes() { Voltar às Atividades e Provas + {renderAppModal()} ); } @@ -854,6 +860,7 @@ export default function Avaliacoes() { })} )} + {renderAppModal()} ); } diff --git a/portal/src/pages/Frequencia.tsx b/portal/src/pages/Frequencia.tsx index c84b91a..e3a280f 100644 --- a/portal/src/pages/Frequencia.tsx +++ b/portal/src/pages/Frequencia.tsx @@ -170,11 +170,29 @@ export default function Frequencia() { }; }); - // Stats calculation (aligned with list logic) - const totalCourseLessons = lessons.length; - const presences = attendance.filter(a => a.type === 'presence').length; - const absences = attendance.filter(a => a.type === 'absence' && !a.justification).length; - const justified = attendance.filter(a => a.type === 'absence' && !!a.justification).length; + // Stats calculation (UNIFIED with list logic) + let presences = 0; + let absences = 0; + let justified = 0; + + processedItems.forEach(item => { + const { lesson, attendances: atts, isCompleted } = item; + if (lesson.status === 'cancelled') return; // Apenas canceladas ficam fora + + const isPresent = atts.some(a => a.type === 'presence' || a.verified === true); + const activeJustification = atts.find(a => !!a.justification); + const hasJustification = !!activeJustification; + + if (isPresent) { + presences++; + } else if (hasJustification) { + justified++; + } else if (isCompleted || parseLessonDateTime(lesson.date || '', '23:59:59') < now.getTime()) { + absences++; + } + }); + + const totalCourseLessons = lessons.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; @@ -405,6 +423,7 @@ export default function Frequencia() { Data + Turma Horário Status de Aula Presença @@ -436,6 +455,11 @@ export default function Frequencia() { backgroundColor: isJustificationAccepted ? 'rgba(245, 158, 11, 0.08)' : 'transparent', }}> {formatDateFull(lesson.date)} + + + {(lesson as any).className || '—'} + + {typeof lesson.startTime === 'string' ? ( @@ -519,7 +543,7 @@ export default function Frequencia() { Justificativa Pendente - ) : (isCompleted || parseLessonDateTime(lesson.date || '', '23:59:59') < now.getTime()) ? ( + ) : (isCompleted || parseLessonDateTime(lesson.date || '', '23:59:59') < now.getTime()) && !isCancelled ? ( Falta