Fix: frequency visibility and justification submission tracking in Portal

This commit is contained in:
Sidney 2026-05-14 13:34:17 -03:00
parent d954fc801d
commit f1f1c0e84a
5 changed files with 28 additions and 14 deletions

View File

@ -62,3 +62,4 @@
33. **Reverse Sync Requirement (SQL -> JSON)**: To ensure visual consistency and avoid automation errors (like double billing), the system MUST perform a reverse sync from `alunos_cobrancas` (SQL) to `school_data.payments` (JSON) during server initialization and on-demand via the `/api/admin/sync-finance-json` endpoint. 33. **Reverse Sync Requirement (SQL -> JSON)**: To ensure visual consistency and avoid automation errors (like double billing), the system MUST perform a reverse sync from `alunos_cobrancas` (SQL) to `school_data.payments` (JSON) during server initialization and on-demand via the `/api/admin/sync-finance-json` endpoint.
34. **Deletion & Notification Order (Async)**: When processing deletions that trigger notifications (e.g., WhatsApp via Asaas Webhook), local database deletion MUST happen only AFTER the notification dispatch. Manual deletion routes should only trigger the external API delete and wait for the webhook to finalize local cleanup, ensuring data availability for message variables. 34. **Deletion & Notification Order (Async)**: When processing deletions that trigger notifications (e.g., WhatsApp via Asaas Webhook), local database deletion MUST happen only AFTER the notification dispatch. Manual deletion routes should only trigger the external API delete and wait for the webhook to finalize local cleanup, ensuring data availability for message variables.
35. **Mass Send Standard (V3)**: The mass send feature MUST use the student's or guardian's first name for the {nome} variable (via `.split(' ')[0]`). It MUST support dual dispatch (sending to both student and guardian if phones are distinct) and allow attachments (Image/PDF) handled via `multipart/form-data` and the Evolution API `sendMedia` endpoint. 35. **Mass Send Standard (V3)**: The mass send feature MUST use the student's or guardian's first name for the {nome} variable (via `.split(' ')[0]`). It MUST support dual dispatch (sending to both student and guardian if phones are distinct) and allow attachments (Image/PDF) handled via `multipart/form-data` and the Evolution API `sendMedia` endpoint.
36. **Frequency Visibility & Justification Tracking**: All attendance records of type `absence` MUST be excluded from the "Presence Time" calculation in the Portal, showing a dash (`—`) instead. Justifications MUST store the `submittedAt` timestamp in both SQL and JSON formats to allow auditing and clear display of submission time in the UI.

View File

@ -41,13 +41,17 @@
- [x] **Feedback Real de Exclusão:** Implementado `showAlert` detalhado no Financeiro e confirmação real via **Sino de Notificações** (Admin) após o processamento do Webhook. - [x] **Feedback Real de Exclusão:** Implementado `showAlert` detalhado no Financeiro e confirmação real via **Sino de Notificações** (Admin) após o processamento do Webhook.
- [x] **Refinamento do Sino (AdminBell):** O botão "Ver Anexo" agora é exclusivo de notificações de justificativa que possuem arquivo físico, ocultando-se em notificações de sistema que usam o campo anexo para metadados JSON. - [x] **Refinamento do Sino (AdminBell):** O botão "Ver Anexo" agora é exclusivo de notificações de justificativa que possuem arquivo físico, ocultando-se em notificações de sistema que usam o campo anexo para metadados JSON.
- [x] **Blindagem de UI (Financeiro):** Corrigido potencial erro de referência no parse de erro do Asaas que causava "White Screen" em exclusões negadas. - [x] **Blindagem de UI (Financeiro):** Corrigido potencial erro de referência no parse de erro do Asaas que causava "White Screen" em exclusões negadas.
- [x] **Correção de Visibilidade (Frequência):** Resolvido bug onde faltas verificadas apareciam como "Presente" no Portal devido a lógica de filtro incompleta.
- [x] **Hora Presença Inteligente:** Coluna de horário no Portal agora exibe `—` para faltas e justificativas, eliminando a confusão com horários de aula.
- [x] **Rastreamento de Justificativas:** Implementado campo `submittedAt` para gravar o momento exato do envio da justificativa (JSON e SQL).
- [x] **Detalhamento de Envio:** Portal agora exibe "Enviada em: DD/MM às HH:MM" na lista de frequência para transparência do aluno.
## 📋 Próximos Passos ## 📋 Próximos Passos
- [ ] Iniciar a migração do módulo Financeiro para 100% SQL seguindo o padrão do Boletim. - [ ] Iniciar a migração do módulo Financeiro para 100% SQL seguindo o padrão do Boletim.
- [ ] Módulo Financeiro SQL: Iniciar a migração total do financeiro para PostgreSQL (padrão `notas_boletim`). - [ ] Módulo Financeiro SQL: Iniciar a migração total do financeiro para PostgreSQL (padrão `notas_boletim`).
- [x] **Correção de Notas (Boletim):** Resolvido bug de visibilidade onde as notas não apareciam devido a race conditions no carregamento e mapeamento incompleto do `examId`. - [x] **Correção de Notas (Boletim):** Resolvido `ReferenceError: subsMap is not defined` que impedia o carregamento das notas individuais.
- [x] **Git Push Realizado:** Correções de deploy e visibilidade de notas enviadas ao repositório. - [x] **Git Push Realizado:** Correções de escopo enviadas ao repositório.
- [ ] **Monitoramento:** Validar a exibição das notas após o reinício dos containers. - [ ] **Monitoramento:** Validar a exibição das notas após o reinício dos containers.
- [ ] Otimização de Build: Re-explorar o cache do Docker. - [ ] Otimização de Build: Re-explorar o cache do Docker.

View File

@ -322,16 +322,17 @@ app.post('/api/portal/frequencia/justificar', authMiddleware, upload.single('arq
const fullDateStr = date; const fullDateStr = date;
const justificationPayload = JSON.stringify({ motivo: motivo.trim(), arquivo: publicUrl }); const justificationPayload = JSON.stringify({ motivo: motivo.trim(), arquivo: publicUrl });
let recordIndex = attendance.findIndex(a => a.studentId === req.user.studentId && a.date === fullDateStr); const submittedAt = new Date().toISOString();
if (recordIndex !== -1) { if (recordIndex !== -1) {
const existing = attendance[recordIndex]; const existing = attendance[recordIndex];
if (existing.type === 'presence') return res.status(400).json({ error: 'Não é possível justificar uma presença' }); if (existing.type === 'presence') return res.status(400).json({ error: 'Não é possível justificar uma presença' });
attendance[recordIndex] = { ...existing, justification: justificationPayload }; attendance[recordIndex] = { ...existing, justification: justificationPayload, submittedAt };
} else { } else {
const newRecord = { const newRecord = {
id: `att-just-${Date.now()}`, studentId: req.user.studentId, classId: student?.classId || '', id: `att-just-${Date.now()}`, studentId: req.user.studentId, classId: student?.classId || '',
date: fullDateStr, verified: false, type: 'absence', justification: justificationPayload, date: fullDateStr, verified: false, type: 'absence', justification: justificationPayload,
submittedAt
}; };
attendance.push(newRecord); attendance.push(newRecord);
recordIndex = attendance.length - 1; recordIndex = attendance.length - 1;
@ -365,10 +366,10 @@ app.post('/api/portal/frequencia/justificar', authMiddleware, upload.single('arq
// Sincronização Imediata com Tabela Relacional // Sincronização Imediata com Tabela Relacional
try { try {
await pool.query( await pool.query(
`INSERT INTO frequencias (id, aluno_id, turma_id, data, verificado, tipo, justificativa) `INSERT INTO frequencias (id, aluno_id, turma_id, data, verificado, tipo, justificativa, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (id) DO UPDATE SET justificativa = EXCLUDED.justificativa, justificativa_aceita = FALSE`, ON CONFLICT (id) DO UPDATE SET justificativa = EXCLUDED.justificativa, justificativa_aceita = FALSE, created_at = EXCLUDED.created_at`,
[attendance[recordIndex].id, req.user.studentId, student?.classId || '', fullDateStr, false, 'absence', justificationPayload] [attendance[recordIndex].id, req.user.studentId, student?.classId || '', fullDateStr, false, 'absence', justificationPayload, submittedAt]
); );
} catch (dbErr) { } catch (dbErr) {
console.error('[Portal:Justificação] Erro ao sincronizar tabela relacional:', dbErr.message); console.error('[Portal:Justificação] Erro ao sincronizar tabela relacional:', dbErr.message);

View File

@ -448,8 +448,8 @@ export default function Frequencia() {
const isCancelled = lesson.status === 'cancelled'; const isCancelled = lesson.status === 'cancelled';
const isRescheduled = lesson.status === 'rescheduled'; const isRescheduled = lesson.status === 'rescheduled';
// PREREQUISITE: 'presence' type OR verified status counts as real presence // PREREQUISITE: 'presence' type OR verified (but NOT absence) counts as real presence
const isPresent = atts.some(a => a.type === 'presence' || a.verified === true); const isPresent = atts.some(a => a.type === 'presence' || (a.verified === true && a.type !== 'absence'));
const hasJustification = atts.some(a => !!a.justification); const hasJustification = atts.some(a => !!a.justification);
const activeJustification = atts.find(a => !!a.justification); const activeJustification = atts.find(a => !!a.justification);
const justText = parseJustification(activeJustification?.justification); const justText = parseJustification(activeJustification?.justification);
@ -571,7 +571,7 @@ export default function Frequencia() {
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{atts.length > 0 ? ( {atts.length > 0 ? (
atts atts
.filter(a => a.type === 'presence' || a.verified) .filter(a => a.type === 'presence' || (a.verified === true && a.type !== 'absence'))
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()) .sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
.map((a, aIdx) => { .map((a, aIdx) => {
const d = new Date(a.date); const d = new Date(a.date);
@ -635,9 +635,16 @@ export default function Frequencia() {
</td> </td>
<td> <td>
{justText ? ( {justText ? (
<span style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)', maxWidth: 250, display: 'block', wordBreak: 'break-word' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{justText} <span style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)', maxWidth: 250, display: 'block', wordBreak: 'break-word' }}>
</span> {justText}
</span>
{activeJustification?.submittedAt && (
<span style={{ fontSize: '0.65rem', color: 'var(--color-text-secondary)', opacity: 0.8 }}>
Enviada em: {new Date(activeJustification.submittedAt).toLocaleDateString('pt-BR')} às {new Date(activeJustification.submittedAt).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}
</span>
)}
</div>
) : ( ) : (
<span style={{ color: 'var(--color-text-secondary)' }}></span> <span style={{ color: 'var(--color-text-secondary)' }}></span>
)} )}

View File

@ -65,6 +65,7 @@ export interface Attendance {
type?: 'presence' | 'absence'; type?: 'presence' | 'absence';
justification?: string; // string (upload em base64 ou texto do motivo) justification?: string; // string (upload em base64 ou texto do motivo)
justificationAccepted?: boolean; justificationAccepted?: boolean;
submittedAt?: string; // ISO string
} }
export interface Class { export interface Class {