From 9a09d7852a058b547e9fe95e02dbbe597e6c9d5c Mon Sep 17 00:00:00 2001 From: Sidney Date: Tue, 5 May 2026 08:48:37 -0300 Subject: [PATCH] feat: migracao relacional da frequencia, correcoes no boletim e novos cards analiticos --- MEMORY.md | 18 ++++++++--------- manager/fix_db.js | 12 +++++++++++ manager/server.selfhosted.js | 2 +- manager/services/database.js | 13 ++++++++---- portal/server.selfhosted.js | 23 +++++++++++++++++---- portal/src/pages/Frequencia.tsx | 36 ++++++++++++++++++++++++++------- 6 files changed, 79 insertions(+), 25 deletions(-) create mode 100644 manager/fix_db.js diff --git a/MEMORY.md b/MEMORY.md index 5b0aa43..21b011d 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -5,15 +5,15 @@ > NUNCA execute `git add`, `git commit` ou `git push` sem que o USUÁRIO solicite explicitamente. Alterações devem ser feitas nos arquivos, mas o envio ao repositório remoto é uma ação exclusiva do usuário. Aguarde sempre o comando direto do usuário para realizar qualquer operação de versionamento. > **ESTA REGRA É INVIOLÁVEL E O ASSISTENTE JÁ FALHOU NELA ANTERIORMENTE. NÃO REPITA O ERRO.** -- [x] **Unificação de Rede (Infra):** Redes unificadas no `docker-compose.yml`, garantindo conectividade. -- [x] **Correção de Constraints (DB):** Removidas fkeys impeditivas na tabela `provas_submissoes`. -- [x] **Sincronização Automática (JSON -> Tabelas):** Implementada função de espelhamento total (Alunos, Turmas, Provas, Frequência, Períodos e Notas). **VERIFICADO.** -- [x] **Correção da Média Geral (Boletim):** O sistema agora busca médias reais diretamente do PostgreSQL, eliminando o bug do `0.00` no Manager. -- [x] **Persistência Inteligente de Notas:** Avaliações agora permanecem no Boletim se o aluno já as realizou, independente de estarem em Rascunho ou Publicadas. -- [x] **Cadeado de Retentativa (Portal):** Implementada trava visual e lógica que impede ou permite que alunos refaçam provas no portal conforme configuração do professor. -- [x] **Duplicação de Avaliações:** Nova ferramenta para clonar provas/atividades entre turmas diferentes com um clique. -- [!] **Incidente de Workflow (01/05/2026):** O assistente realizou um `git push` não autorizado. A regra foi reforçada. -- [ ] Próximo Passo: Monitorar o desempenho das consultas nas tabelas relacionais à medida que o volume de submissões aumenta. +- [x] **Correção Estrutural (Boletim):** Resolvida divergência de tabelas entre `notas` e `notas_boletim` no Manager, restaurando a exibição de médias. +- [x] **Frequência Analítica (Portal):** Cards de estatísticas (Presença/Falta) agora usam a mesma lógica da lista (considerando `verified` e justificativas). +- [x] **Nova Métrica de Justificativas:** Adicionado card exclusivo no Portal para acompanhamento de justificativas enviadas. +- [x] **Detalhamento de Progresso de Aulas:** Card de "Total de Aulas" agora exibe aulas concluídas e aulas a concluir. +- [x] **Migração Relacional de Frequência:** Portal migrado para ler frequências diretamente da tabela SQL `frequencias`. **VERIFICADO.** +- [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`). +- [ ] Próximo Passo: Expandir a migração relacional para o módulo de "Minhas Aulas" no Portal para manter a consistência arquitetural. + ## 📅 Histórico Anterior (22/04/2026) diff --git a/manager/fix_db.js b/manager/fix_db.js new file mode 100644 index 0000000..47759cf --- /dev/null +++ b/manager/fix_db.js @@ -0,0 +1,12 @@ + +const pg = require('pg'); +const pool = new pg.Pool({ connectionString: 'postgresql://edumanager:EduManager2026!Seguro@postgres:5432/edumanager' }); +async function run() { + try { + await pool.query("ALTER TABLE provas ADD COLUMN IF NOT EXISTS allow_retake BOOLEAN DEFAULT FALSE"); + await pool.query("ALTER TABLE provas ADD COLUMN IF NOT EXISTS evaluation_type TEXT DEFAULT 'exam'"); + await pool.query("ALTER TABLE provas ADD COLUMN IF NOT EXISTS max_score NUMERIC(5,2) DEFAULT 10.0"); + console.log('✅ Banco de dados atualizado!'); + } catch (e) { console.error(e); } finally { await pool.end(); } +} +run(); diff --git a/manager/server.selfhosted.js b/manager/server.selfhosted.js index ebbadbc..88fcc0b 100644 --- a/manager/server.selfhosted.js +++ b/manager/server.selfhosted.js @@ -327,7 +327,7 @@ app.get('/api/student-submissions/:studentId', async (req, res) => { app.get('/api/notas/:alunoId', async (req, res) => { try { const { rows: dbNotas } = await pool.query( - 'SELECT id, aluno_id as "aluno_id", disciplina_id as "disciplina_id", periodo_id as "periodo_id", prova_id as "prova_id", valor as "valor" FROM notas WHERE TRIM(aluno_id) = TRIM($1)', + 'SELECT id, aluno_id as "aluno_id", disciplina_id as "disciplina_id", periodo_id as "periodo_id", prova_id as "prova_id", valor as "valor" FROM notas_boletim WHERE TRIM(aluno_id) = TRIM($1)', [String(req.params.alunoId).trim()] ); // Garantir cast numérico para evitar erro de .toFixed no frontend diff --git a/manager/services/database.js b/manager/services/database.js index 85cc7ca..cf027f9 100644 --- a/manager/services/database.js +++ b/manager/services/database.js @@ -286,6 +286,10 @@ export async function syncJsonToRelationalTables() { console.log('[Sincronização] 🔄 Iniciando espelhamento TOTAL (Modo Blindado)...'); await client.query('BEGIN'); + // Garantir colunas na tabela frequencias + await client.query(`ALTER TABLE frequencias ADD COLUMN IF NOT EXISTS justificativa TEXT`); + await client.query(`ALTER TABLE frequencias ADD COLUMN IF NOT EXISTS justificativa_aceita BOOLEAN DEFAULT FALSE`); + // 1. Sincronizar Cursos if (data.courses && Array.isArray(data.courses)) { for (const c of data.courses) { @@ -399,12 +403,13 @@ export async function syncJsonToRelationalTables() { 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, data, foto, verificado, tipo) - VALUES ($1, $2, $3, $4, $5, $6, $7) + `INSERT INTO frequencias (id, aluno_id, turma_id, data, foto, verificado, tipo, justificativa, justificativa_aceita) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT (id) DO UPDATE SET aluno_id = EXCLUDED.aluno_id, turma_id = EXCLUDED.turma_id, data = EXCLUDED.data, - foto = EXCLUDED.foto, verificado = EXCLUDED.verificado, tipo = EXCLUDED.tipo`, - [f.id, f.studentId, f.classId, f.date, f.photo || '', f.verified || false, f.type || 'presence'] + foto = EXCLUDED.foto, verificado = EXCLUDED.verificado, tipo = EXCLUDED.tipo, + justificativa = EXCLUDED.justificativa, justificativa_aceita = EXCLUDED.justificativa_aceita`, + [f.id, f.studentId, f.classId, f.date, f.photo || '', f.verified || false, f.type || 'presence', f.justification || null, f.justificationAccepted || false] ); } } diff --git a/portal/server.selfhosted.js b/portal/server.selfhosted.js index 2b73213..bfc346b 100644 --- a/portal/server.selfhosted.js +++ b/portal/server.selfhosted.js @@ -290,13 +290,16 @@ app.get('/api/portal/notas', authMiddleware, async (req, res) => { } }); -// GET /api/portal/frequencia +// GET /api/portal/frequencia (Relacional) 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); - res.json({ attendance }); + const { rows: dbAttendance } = await pool.query( + 'SELECT id, aluno_id as "studentId", turma_id as "classId", data as "date", foto as "photo", verificado as "verified", tipo as "type", justificativa as "justification", justificativa_aceita as "justificationAccepted" FROM frequencias WHERE aluno_id = $1', + [req.user.studentId] + ); + res.json({ attendance: dbAttendance }); } catch (err) { + console.error('Frequencia error:', err); res.status(500).json({ error: 'Erro interno' }); } }); @@ -355,6 +358,18 @@ app.post('/api/portal/frequencia/justificar', authMiddleware, upload.single('arq schoolData.lastUpdated = new Date().toISOString(); await saveSchoolData(schoolData); + // Sincronização Imediata com Tabela Relacional + try { + await pool.query( + `INSERT INTO frequencias (id, aluno_id, turma_id, data, verificado, tipo, justificativa) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (id) DO UPDATE SET justificativa = EXCLUDED.justificativa, justificativa_aceita = FALSE`, + [attendance[recordIndex].id, req.user.studentId, student?.classId || '', fullDateStr, false, 'absence', justificationPayload] + ); + } catch (dbErr) { + console.error('[Portal:Justificação] Erro ao sincronizar tabela relacional:', dbErr.message); + } + res.json({ message: 'Justificativa enviada com sucesso', record: attendance[recordIndex] }); } catch (err) { console.error('Justificativa error:', err); diff --git a/portal/src/pages/Frequencia.tsx b/portal/src/pages/Frequencia.tsx index 90cab0c..2f17de0 100644 --- a/portal/src/pages/Frequencia.tsx +++ b/portal/src/pages/Frequencia.tsx @@ -134,11 +134,7 @@ export default function Frequencia() { ); } - // Stats calculation (based on total course schedule vs presences) - const totalCourseLessons = lessons.length; - const presences = attendance.filter(a => a.type === 'presence').length; - const absences = attendance.filter(a => a.type === 'absence').length; - const percentage = totalCourseLessons > 0 ? Math.round((presences / totalCourseLessons) * 100) : 0; + // Merge and Categorize const processedItems = lessons.map(lesson => { @@ -174,6 +170,15 @@ export default function Frequencia() { }; }); + // Stats calculation (aligned with list logic) + const totalCourseLessons = lessons.length; + const presences = attendance.filter(a => a.type === 'presence' || a.verified === true).length; + const absences = attendance.filter(a => a.type === 'absence' && !a.verified && !a.justification).length; + const justified = attendance.filter(a => !!a.justification).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; + const activeItems = processedItems.filter(item => !item.isCompleted && item.lesson.status !== 'cancelled').sort((a, b) => { const dateA = parseLessonDateTime(a.lesson.date, a.lesson.startTime, 0); const dateB = parseLessonDateTime(b.lesson.date, b.lesson.startTime, 0); @@ -319,11 +324,28 @@ export default function Frequencia() {
-

{totalCourseLessons}

+

{justified}

- TOTAL DE AULAS DO CURSO + JUSTIFICATIVAS

+ +
+

{totalCourseLessons}

+

+ TOTAL DE AULAS +

+
+
+

{completedLessons}

+

CONCLUÍDAS

+
+
+

{pendingLessons}

+

A CONCLUIR

+
+
+