feat: migracao relacional da frequencia, correcoes no boletim e novos cards analiticos

This commit is contained in:
Sidney 2026-05-05 08:48:37 -03:00
parent 402ef4b389
commit 9a09d7852a
6 changed files with 79 additions and 25 deletions

View File

@ -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)

12
manager/fix_db.js Normal file
View File

@ -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();

View File

@ -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

View File

@ -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]
);
}
}

View File

@ -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);

View File

@ -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() {
</div>
<div className="glass-card" style={{ padding: '1.25rem', textAlign: 'center' }}>
<p style={{ fontSize: '2rem', fontWeight: 700 }}>{totalCourseLessons}</p>
<p style={{ fontSize: '2rem', fontWeight: 700, color: '#f59e0b' }}>{justified}</p>
<p style={{ fontSize: '0.75rem', color: 'var(--color-text-secondary)', fontWeight: 500, marginTop: 4 }}>
TOTAL DE AULAS DO CURSO
JUSTIFICATIVAS
</p>
</div>
<div className="glass-card" style={{ padding: '1.25rem', textAlign: 'center', display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
<p style={{ fontSize: '1.5rem', fontWeight: 800, color: 'var(--color-text)' }}>{totalCourseLessons}</p>
<p style={{ fontSize: '0.65rem', color: 'var(--color-text-secondary)', fontWeight: 600, marginBottom: '0.75rem', letterSpacing: '0.05em' }}>
TOTAL DE AULAS
</p>
<div style={{ display: 'flex', borderTop: '1px solid var(--glass-border)', paddingTop: '0.75rem', gap: '0.5rem' }}>
<div style={{ flex: 1 }}>
<p style={{ fontSize: '1rem', fontWeight: 800, color: 'var(--color-success)' }}>{completedLessons}</p>
<p style={{ fontSize: '0.6rem', color: 'var(--color-text-secondary)', fontWeight: 500 }}>CONCLUÍDAS</p>
</div>
<div style={{ flex: 1, borderLeft: '1px solid var(--glass-border)' }}>
<p style={{ fontSize: '1rem', fontWeight: 800, color: 'var(--color-primary)' }}>{pendingLessons}</p>
<p style={{ fontSize: '0.6rem', color: 'var(--color-text-secondary)', fontWeight: 500 }}>A CONCLUIR</p>
</div>
</div>
</div>
</div>
<div style={{