feat: migracao relacional da frequencia, correcoes no boletim e novos cards analiticos
This commit is contained in:
parent
402ef4b389
commit
9a09d7852a
18
MEMORY.md
18
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.
|
> 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.**
|
> **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 Estrutural (Boletim):** Resolvida divergência de tabelas entre `notas` e `notas_boletim` no Manager, restaurando a exibição de médias.
|
||||||
- [x] **Correção de Constraints (DB):** Removidas fkeys impeditivas na tabela `provas_submissoes`.
|
- [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] **Sincronização Automática (JSON -> Tabelas):** Implementada função de espelhamento total (Alunos, Turmas, Provas, Frequência, Períodos e Notas). **VERIFICADO.**
|
- [x] **Nova Métrica de Justificativas:** Adicionado card exclusivo no Portal para acompanhamento de justificativas enviadas.
|
||||||
- [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] **Detalhamento de Progresso de Aulas:** Card de "Total de Aulas" agora exibe aulas concluídas e aulas a concluir.
|
||||||
- [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] **Migração Relacional de Frequência:** Portal migrado para ler frequências diretamente da tabela SQL `frequencias`. **VERIFICADO.**
|
||||||
- [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] **Sincronização Bidirecional (Frequência):** Garantido que justificativas enviadas pelo Portal atualizem instantaneamente a tabela relacional via `ON CONFLICT`.
|
||||||
- [x] **Duplicação de Avaliações:** Nova ferramenta para clonar provas/atividades entre turmas diferentes com um clique.
|
- [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`).
|
||||||
- [!] **Incidente de Workflow (01/05/2026):** O assistente realizou um `git push` não autorizado. A regra foi reforçada.
|
- [ ] Próximo Passo: Expandir a migração relacional para o módulo de "Minhas Aulas" no Portal para manter a consistência arquitetural.
|
||||||
- [ ] Próximo Passo: Monitorar o desempenho das consultas nas tabelas relacionais à medida que o volume de submissões aumenta.
|
|
||||||
|
|
||||||
|
|
||||||
## 📅 Histórico Anterior (22/04/2026)
|
## 📅 Histórico Anterior (22/04/2026)
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -327,7 +327,7 @@ app.get('/api/student-submissions/:studentId', async (req, res) => {
|
||||||
app.get('/api/notas/:alunoId', async (req, res) => {
|
app.get('/api/notas/:alunoId', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { rows: dbNotas } = await pool.query(
|
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()]
|
[String(req.params.alunoId).trim()]
|
||||||
);
|
);
|
||||||
// Garantir cast numérico para evitar erro de .toFixed no frontend
|
// Garantir cast numérico para evitar erro de .toFixed no frontend
|
||||||
|
|
|
||||||
|
|
@ -286,6 +286,10 @@ export async function syncJsonToRelationalTables() {
|
||||||
console.log('[Sincronização] 🔄 Iniciando espelhamento TOTAL (Modo Blindado)...');
|
console.log('[Sincronização] 🔄 Iniciando espelhamento TOTAL (Modo Blindado)...');
|
||||||
await client.query('BEGIN');
|
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
|
// 1. Sincronizar Cursos
|
||||||
if (data.courses && Array.isArray(data.courses)) {
|
if (data.courses && Array.isArray(data.courses)) {
|
||||||
for (const c of data.courses) {
|
for (const c of data.courses) {
|
||||||
|
|
@ -399,12 +403,13 @@ export async function syncJsonToRelationalTables() {
|
||||||
for (const f of data.attendance) {
|
for (const f of data.attendance) {
|
||||||
if (!f.id || !f.studentId || !f.classId) continue;
|
if (!f.id || !f.studentId || !f.classId) continue;
|
||||||
await client.query(
|
await client.query(
|
||||||
`INSERT INTO frequencias (id, aluno_id, turma_id, data, foto, verificado, tipo)
|
`INSERT INTO frequencias (id, aluno_id, turma_id, data, foto, verificado, tipo, justificativa, justificativa_aceita)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
ON CONFLICT (id) DO UPDATE SET
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
aluno_id = EXCLUDED.aluno_id, turma_id = EXCLUDED.turma_id, data = EXCLUDED.data,
|
aluno_id = EXCLUDED.aluno_id, turma_id = EXCLUDED.turma_id, data = EXCLUDED.data,
|
||||||
foto = EXCLUDED.foto, verificado = EXCLUDED.verificado, tipo = EXCLUDED.tipo`,
|
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']
|
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]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
app.get('/api/portal/frequencia', authMiddleware, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const schoolData = await getSchoolData();
|
const { rows: dbAttendance } = await pool.query(
|
||||||
const attendance = (schoolData.attendance || []).filter((a) => a.studentId === req.user.studentId);
|
'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',
|
||||||
res.json({ attendance });
|
[req.user.studentId]
|
||||||
|
);
|
||||||
|
res.json({ attendance: dbAttendance });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error('Frequencia error:', err);
|
||||||
res.status(500).json({ error: 'Erro interno' });
|
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();
|
schoolData.lastUpdated = new Date().toISOString();
|
||||||
await saveSchoolData(schoolData);
|
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] });
|
res.json({ message: 'Justificativa enviada com sucesso', record: attendance[recordIndex] });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Justificativa error:', err);
|
console.error('Justificativa error:', err);
|
||||||
|
|
|
||||||
|
|
@ -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
|
// Merge and Categorize
|
||||||
const processedItems = lessons.map(lesson => {
|
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 activeItems = processedItems.filter(item => !item.isCompleted && item.lesson.status !== 'cancelled').sort((a, b) => {
|
||||||
const dateA = parseLessonDateTime(a.lesson.date, a.lesson.startTime, 0);
|
const dateA = parseLessonDateTime(a.lesson.date, a.lesson.startTime, 0);
|
||||||
const dateB = parseLessonDateTime(b.lesson.date, b.lesson.startTime, 0);
|
const dateB = parseLessonDateTime(b.lesson.date, b.lesson.startTime, 0);
|
||||||
|
|
@ -319,11 +324,28 @@ export default function Frequencia() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="glass-card" style={{ padding: '1.25rem', textAlign: 'center' }}>
|
<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 }}>
|
<p style={{ fontSize: '0.75rem', color: 'var(--color-text-secondary)', fontWeight: 500, marginTop: 4 }}>
|
||||||
TOTAL DE AULAS DO CURSO
|
JUSTIFICATIVAS
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div style={{
|
<div style={{
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue