feat: unified notification system with SQL and WhatsApp integration
This commit is contained in:
parent
bafd1a6292
commit
9e44ce0712
|
|
@ -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.
|
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.
|
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.
|
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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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] **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] **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] **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.
|
- [ ] 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] **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] **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).
|
- [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] **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] **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.
|
- [x] **Fix Memory Leak:** Removido `pool.on('error')` que estava dentro da rota `PUT /api/school-data`, acumulando listeners a cada salvamento.
|
||||||
|
|
|
||||||
|
|
@ -14,23 +14,42 @@ const AdminNotifications: React.FC<Props> = ({ data, updateData, setView, onNavi
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [viewingAttachment, setViewingAttachment] = useState<string | null>(null);
|
const [viewingAttachment, setViewingAttachment] = useState<string | null>(null);
|
||||||
const [notifWithAttachment, setNotifWithAttachment] = useState<Notification | null>(null);
|
const [notifWithAttachment, setNotifWithAttachment] = useState<Notification | null>(null);
|
||||||
|
const [adminNotifs, setAdminNotifs] = useState<Notification[]>([]);
|
||||||
const prevCountRef = useRef<number>(0);
|
const prevCountRef = useRef<number>(0);
|
||||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
|
||||||
const handleDeleteAttachment = () => {
|
const handleDeleteAttachment = async () => {
|
||||||
if (!notifWithAttachment) return;
|
if (!notifWithAttachment) return;
|
||||||
|
try {
|
||||||
const updatedNotifs = (data.notifications || []).map(n =>
|
const resp = await fetch(`/api/notificacoes/remover-anexo/${notifWithAttachment.id}`, { method: 'PUT' });
|
||||||
n.id === notifWithAttachment.id ? { ...n, attachment: undefined } : n
|
if (resp.ok) {
|
||||||
);
|
setAdminNotifs(prev => prev.map(n => n.id === notifWithAttachment.id ? { ...n, attachment: undefined } : n));
|
||||||
|
setViewingAttachment(null);
|
||||||
updateData({ notifications: updatedNotifs });
|
setNotifWithAttachment(null);
|
||||||
dbService.saveData({ ...data, notifications: updatedNotifs });
|
}
|
||||||
setViewingAttachment(null);
|
} catch (e) {
|
||||||
setNotifWithAttachment(null);
|
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;
|
const unreadCount = adminNotifs.filter(n => !n.read).length;
|
||||||
|
|
||||||
// Som de notificação quando chega uma nova
|
// Som de notificação quando chega uma nova
|
||||||
|
|
@ -73,18 +92,26 @@ const AdminNotifications: React.FC<Props> = ({ data, updateData, setView, onNavi
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMarkAsRead = (id: string) => {
|
const handleMarkAsRead = async (id: string) => {
|
||||||
const updatedAll = (data.notifications || []).map(n =>
|
try {
|
||||||
n.id === id ? { ...n, read: true } : n
|
const resp = await fetch(`/api/notificacoes/ler/${id}`, { method: 'PUT' });
|
||||||
);
|
if (resp.ok) {
|
||||||
updateData({ notifications: updatedAll });
|
setAdminNotifs(prev => prev.map(n => n.id === id ? { ...n, read: true } : n));
|
||||||
dbService.saveData({ ...data, notifications: updatedAll });
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Erro ao marcar como lida:', e);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClearRead = () => {
|
const handleClearRead = async () => {
|
||||||
const others = (data.notifications || []).filter(n => n.studentId !== 'admin' || (n.studentId === 'admin' && !n.read));
|
try {
|
||||||
updateData({ notifications: others });
|
const resp = await fetch('/api/notificacoes/limpar-lidas', { method: 'DELETE' });
|
||||||
dbService.saveData({ ...data, notifications: others });
|
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
|
// Aceitar justificativa diretamente pela notificação
|
||||||
|
|
@ -104,12 +131,10 @@ const AdminNotifications: React.FC<Props> = ({ data, updateData, setView, onNavi
|
||||||
const updatedAttendance = (data.attendance || []).map(a =>
|
const updatedAttendance = (data.attendance || []).map(a =>
|
||||||
a.id === matchedAbsence.id ? { ...a, justificationAccepted: true } : 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 });
|
updateData({ attendance: updatedAttendance });
|
||||||
dbService.saveData({ ...data, attendance: updatedAttendance, notifications: updatedNotifs });
|
dbService.saveData({ ...data, attendance: updatedAttendance });
|
||||||
|
handleMarkAsRead(notif.id);
|
||||||
} else {
|
} else {
|
||||||
// Se não encontrou pendentes, apenas marca como lida
|
// Se não encontrou pendentes, apenas marca como lida
|
||||||
handleMarkAsRead(notif.id);
|
handleMarkAsRead(notif.id);
|
||||||
|
|
|
||||||
|
|
@ -484,11 +484,16 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{(() => {
|
{(() => {
|
||||||
const now = new Date();
|
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 || [])
|
const actualRecords = (data.attendance || [])
|
||||||
.filter(a => a.studentId === selectedStudent.id && a.classId === selectedClass.id);
|
.filter(a => a.studentId === selectedStudent.id);
|
||||||
|
|
||||||
const classLessonsRaw = (data.lessons || [])
|
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) =>
|
const deduplicatedLessons = classLessonsRaw.filter((lesson, index, self) =>
|
||||||
index === self.findIndex((t) => (
|
index === self.findIndex((t) => (
|
||||||
|
|
@ -515,7 +520,7 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
|
||||||
record = {
|
record = {
|
||||||
id: `v-${lesson.id}`,
|
id: `v-${lesson.id}`,
|
||||||
studentId: selectedStudent.id,
|
studentId: selectedStudent.id,
|
||||||
classId: selectedClass.id,
|
classId: lesson.classId || selectedClass.id,
|
||||||
date: `${lesson.date}T${lesson.startTime || '00:00'}:00`,
|
date: `${lesson.date}T${lesson.startTime || '00:00'}:00`,
|
||||||
type: isFinished ? 'absence' : 'awaiting',
|
type: isFinished ? 'absence' : 'awaiting',
|
||||||
isVirtual: true,
|
isVirtual: true,
|
||||||
|
|
@ -577,6 +582,7 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
|
||||||
<thead className="bg-slate-50 text-slate-500 text-[10px] uppercase font-bold tracking-wider sticky top-0">
|
<thead className="bg-slate-50 text-slate-500 text-[10px] uppercase font-bold tracking-wider sticky top-0">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-4 text-sm">Data</th>
|
<th className="px-6 py-4 text-sm">Data</th>
|
||||||
|
<th className="px-6 py-4 text-sm">Turma</th>
|
||||||
<th className="px-6 py-4 text-sm">Início (Aula)</th>
|
<th className="px-6 py-4 text-sm">Início (Aula)</th>
|
||||||
<th className="px-6 py-4 text-sm">Término (Aula)</th>
|
<th className="px-6 py-4 text-sm">Término (Aula)</th>
|
||||||
<th className="px-6 py-4 text-sm">Registro</th>
|
<th className="px-6 py-4 text-sm">Registro</th>
|
||||||
|
|
@ -612,6 +618,9 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
|
||||||
<td className="px-6 py-4 text-base font-bold text-slate-800">
|
<td className="px-6 py-4 text-base font-bold text-slate-800">
|
||||||
{recordDate.toLocaleDateString('pt-BR')}
|
{recordDate.toLocaleDateString('pt-BR')}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm font-bold text-slate-600">
|
||||||
|
{data.classes.find(c => c.id === lesson?.classId)?.name || '—'}
|
||||||
|
</td>
|
||||||
<td className="px-6 py-4 text-sm font-bold text-indigo-600">
|
<td className="px-6 py-4 text-sm font-bold text-indigo-600">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Clock size={14} className="text-slate-400" /> {lesson?.startTime || '--:--'}
|
<Clock size={14} className="text-slate-400" /> {lesson?.startTime || '--:--'}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import React, { useState, useRef } from 'react';
|
import React, { useState, useRef } from 'react';
|
||||||
import { SchoolData, Exam, Question } from '../types';
|
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 { uploadExamImage } from '../services/supabase';
|
||||||
import { useDialog } from '../DialogContext';
|
import { useDialog } from '../DialogContext';
|
||||||
import { dbService } from '../services/dbService';
|
import { dbService } from '../services/dbService';
|
||||||
|
|
@ -45,7 +45,8 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
|
||||||
status: 'draft',
|
status: 'draft',
|
||||||
questions: [],
|
questions: [],
|
||||||
evaluationType: 'exam',
|
evaluationType: 'exam',
|
||||||
maxScore: 10
|
maxScore: 10,
|
||||||
|
allowRetake: false
|
||||||
} as any);
|
} as any);
|
||||||
setCurrentView('builder');
|
setCurrentView('builder');
|
||||||
};
|
};
|
||||||
|
|
@ -183,6 +184,33 @@ const Exams: React.FC<ExamsProps> = ({ 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') => {
|
const handleSave = (status: 'draft' | 'published') => {
|
||||||
if (!editingExam) return;
|
if (!editingExam) return;
|
||||||
|
|
||||||
|
|
@ -602,6 +630,13 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
|
||||||
>
|
>
|
||||||
<Copy size={18} />
|
<Copy size={18} />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleNotifyStudents(exam)}
|
||||||
|
className="p-2 bg-indigo-50 text-indigo-600 hover:bg-indigo-100 rounded-lg transition-colors"
|
||||||
|
title="Notificar Turma"
|
||||||
|
>
|
||||||
|
<Bell size={18} />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDeleteExam(exam.id)}
|
onClick={() => handleDeleteExam(exam.id)}
|
||||||
className="p-2 bg-red-50 text-red-500 hover:bg-red-100 rounded-lg transition-colors"
|
className="p-2 bg-red-50 text-red-500 hover:bg-red-100 rounded-lg transition-colors"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { SchoolData } from '../types';
|
import { SchoolData } from '../types';
|
||||||
import { useDialog } from '../DialogContext';
|
import { useDialog } from '../DialogContext';
|
||||||
import { MessageSquare, Save, Info, Settings, Send, Clock, AlertTriangle, FileText, CheckCircle, Cake, X, Power } from 'lucide-react';
|
import { MessageSquare, Save, Info, Settings, Send, Clock, AlertTriangle, FileText, CheckCircle, Cake, X, Power, BookOpen } from 'lucide-react';
|
||||||
|
|
||||||
interface MessagesProps {
|
interface MessagesProps {
|
||||||
data: SchoolData;
|
data: SchoolData;
|
||||||
|
|
@ -16,6 +16,7 @@ const defaultTemplates = {
|
||||||
cobrancaAtualizada: "Olá {nome}, o boleto de {descricao} foi atualizado. Segue a nova versão:",
|
cobrancaAtualizada: "Olá {nome}, o boleto de {descricao} foi atualizado. Segue a nova versão:",
|
||||||
boletoAVencer: "Olá {nome}, lembramos que sua cobrança referente a {descricao} no valor de R$ {valor} vencerá em {vencimento}. Segue o PDF abaixo:",
|
boletoAVencer: "Olá {nome}, lembramos que sua cobrança referente a {descricao} no valor de R$ {valor} vencerá em {vencimento}. Segue o PDF abaixo:",
|
||||||
felizAniversario: "Olá {nome}, a equipe da {escola} passa para te desejar um Feliz Aniversário! Muita saúde, paz e conquistas neste novo ciclo! 🎂🎈",
|
felizAniversario: "Olá {nome}, a equipe da {escola} passa para te desejar um Feliz Aniversário! Muita saúde, paz e conquistas neste novo ciclo! 🎂🎈",
|
||||||
|
novaAvaliacao: "Olá {nome}, uma nova {tipo_avaliacao} ({titulo_avaliacao}) de {materia} foi publicada no portal do aluno. Acesse e realize o mais breve possível!",
|
||||||
automationRules: {
|
automationRules: {
|
||||||
sendOnDueDate: true,
|
sendOnDueDate: true,
|
||||||
sendDaysAfter: '1',
|
sendDaysAfter: '1',
|
||||||
|
|
@ -70,7 +71,7 @@ const Messages: React.FC<MessagesProps> = ({ data, updateData }) => {
|
||||||
|
|
||||||
// Modal de Edição de Modelo
|
// Modal de Edição de Modelo
|
||||||
const [editingTemplate, setEditingTemplate] = useState<{
|
const [editingTemplate, setEditingTemplate] = useState<{
|
||||||
key: keyof typeof defaultTemplates | 'felizAniversario',
|
key: keyof typeof defaultTemplates | 'felizAniversario' | 'novaAvaliacao',
|
||||||
label: string,
|
label: string,
|
||||||
desc: string,
|
desc: string,
|
||||||
color: string,
|
color: string,
|
||||||
|
|
@ -141,7 +142,8 @@ const Messages: React.FC<MessagesProps> = ({ data, updateData }) => {
|
||||||
cobrancaCancelada: normalizeLineBreaks(templates.cobrancaCancelada),
|
cobrancaCancelada: normalizeLineBreaks(templates.cobrancaCancelada),
|
||||||
cobrancaAtualizada: normalizeLineBreaks(templates.cobrancaAtualizada),
|
cobrancaAtualizada: normalizeLineBreaks(templates.cobrancaAtualizada),
|
||||||
boletoAVencer: normalizeLineBreaks(templates.boletoAVencer),
|
boletoAVencer: normalizeLineBreaks(templates.boletoAVencer),
|
||||||
felizAniversario: normalizeLineBreaks(templates.felizAniversario)
|
felizAniversario: normalizeLineBreaks(templates.felizAniversario),
|
||||||
|
novaAvaliacao: normalizeLineBreaks(templates.novaAvaliacao || "Olá {nome}, uma nova {tipo_avaliacao} ({titulo_avaliacao}) de {materia} foi publicada no portal do aluno. Acesse e realize o mais breve possível!")
|
||||||
};
|
};
|
||||||
updateData({ messageTemplates: normalizedTemplates });
|
updateData({ messageTemplates: normalizedTemplates });
|
||||||
showAlert('Sucesso', 'Configurações de mensagens salvas com sucesso!', 'success');
|
showAlert('Sucesso', 'Configurações de mensagens salvas com sucesso!', 'success');
|
||||||
|
|
@ -260,7 +262,8 @@ const Messages: React.FC<MessagesProps> = ({ data, updateData }) => {
|
||||||
{ key: 'cobrancaCancelada', label: 'Cobrança Cancelada', desc: 'Enviado quando o boleto for cancelado no sistema.', color: 'slate', icon: AlertTriangle, vars: ['{nome}', '{matricula}', '{descricao}', '{escola}'] },
|
{ key: 'cobrancaCancelada', label: 'Cobrança Cancelada', desc: 'Enviado quando o boleto for cancelado no sistema.', color: 'slate', icon: AlertTriangle, vars: ['{nome}', '{matricula}', '{descricao}', '{escola}'] },
|
||||||
{ key: 'cobrancaAtualizada', label: 'Cobrança Atualizada', desc: 'Enviado quando houver edição/atualização da cobrança.', color: 'amber', icon: Settings, vars: ['{nome}', '{matricula}', '{descricao}', '{valor}', '{vencimento}', '{link_boleto}', '{escola}'] },
|
{ key: 'cobrancaAtualizada', label: 'Cobrança Atualizada', desc: 'Enviado quando houver edição/atualização da cobrança.', color: 'amber', icon: Settings, vars: ['{nome}', '{matricula}', '{descricao}', '{valor}', '{vencimento}', '{link_boleto}', '{escola}'] },
|
||||||
{ key: 'boletoAVencer', label: 'Boleto a Vencer', desc: 'Aviso preventivo enviado dias antes do vencimento.', color: 'indigo', icon: Clock, vars: ['{nome}', '{matricula}', '{descricao}', '{valor}', '{vencimento}', '{link_boleto}', '{escola}'] },
|
{ key: 'boletoAVencer', label: 'Boleto a Vencer', desc: 'Aviso preventivo enviado dias antes do vencimento.', color: 'indigo', icon: Clock, vars: ['{nome}', '{matricula}', '{descricao}', '{valor}', '{vencimento}', '{link_boleto}', '{escola}'] },
|
||||||
{ key: 'felizAniversario', label: 'Feliz Aniversário', desc: 'Mensagem carinhosa para os aniversariantes do dia.', color: 'pink', icon: Cake, vars: ['{nome}', '{escola}'] }
|
{ key: 'felizAniversario', label: 'Feliz Aniversário', desc: 'Mensagem carinhosa para os aniversariantes do dia.', color: 'pink', icon: Cake, vars: ['{nome}', '{escola}'] },
|
||||||
|
{ key: 'novaAvaliacao', label: 'Nova Avaliação', desc: 'Enviado ao aluno quando uma prova ou atividade é publicada.', color: 'indigo', icon: BookOpen, vars: ['{nome}', '{matricula}', '{tipo_avaliacao}', '{titulo_avaliacao}', '{materia}', '{escola}'] }
|
||||||
];
|
];
|
||||||
|
|
||||||
const insertVariable = (variable: string) => {
|
const insertVariable = (variable: string) => {
|
||||||
|
|
|
||||||
|
|
@ -807,6 +807,122 @@ app.post('/api/gerar_cobranca', async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Notificar Alunos sobre Avaliação
|
||||||
|
// ============================================================
|
||||||
|
app.post('/api/exames/notificar', async (req, res) => {
|
||||||
|
const { examId } = req.body;
|
||||||
|
if (!examId) return res.status(400).json({ error: 'ID do exame obrigatório.' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const appData = await getSchoolData();
|
||||||
|
const exam = (appData.exams || []).find(e => e.id === examId);
|
||||||
|
if (!exam) return res.status(404).json({ error: 'Exame não encontrado.' });
|
||||||
|
|
||||||
|
const classObj = (appData.classes || []).find(c => c.id === exam.classId);
|
||||||
|
if (!classObj) return res.status(404).json({ error: 'Turma não encontrada.' });
|
||||||
|
|
||||||
|
const subjectObj = (appData.subjects || []).find(s => s.id === exam.subjectId);
|
||||||
|
const materia = subjectObj ? subjectObj.name : 'sua disciplina';
|
||||||
|
|
||||||
|
const alunos = (appData.students || []).filter(s => s.classId === classObj.id && s.status === 'active');
|
||||||
|
if (alunos.length === 0) return res.status(400).json({ error: 'Nenhum aluno ativo nesta turma.' });
|
||||||
|
|
||||||
|
const evoConfig = appData.evolutionConfig;
|
||||||
|
const msgTemplate = (appData.messageTemplates?.novaAvaliacao) || "Olá {nome}, uma nova {tipo_avaliacao} ({titulo_avaliacao}) de {materia} foi publicada no portal do aluno. Acesse e realize o mais breve possível!";
|
||||||
|
|
||||||
|
const tipoAvaliacao = exam.evaluationType === 'activity' ? 'atividade' : 'prova';
|
||||||
|
|
||||||
|
// 1. Inserir notificações no PostgreSQL (Sino do Portal)
|
||||||
|
for (const aluno of alunos) {
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO notificacoes (aluno_id, titulo, mensagem, lida) VALUES ($1, $2, $3, false)`,
|
||||||
|
[aluno.id, "Nova Avaliação Disponível!", `A ${tipoAvaliacao} "${exam.title}" já está disponível no seu portal.`]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Disparo de WhatsApp em Background
|
||||||
|
if (evoConfig?.apiUrl && evoConfig?.apiKey && evoConfig?.instanceName) {
|
||||||
|
// Background async function
|
||||||
|
(async () => {
|
||||||
|
for (let i = 0; i < alunos.length; i++) {
|
||||||
|
const aluno = alunos[i];
|
||||||
|
const telefone = aluno.phone || aluno.guardianPhone;
|
||||||
|
if (!telefone) continue;
|
||||||
|
|
||||||
|
let cleanPhone = telefone.replace(/\D/g, '');
|
||||||
|
if (cleanPhone.length === 10 || cleanPhone.length === 11) cleanPhone = '55' + cleanPhone;
|
||||||
|
|
||||||
|
const msg = msgTemplate
|
||||||
|
.replace(/{nome}/g, aluno.name.split(' ')[0])
|
||||||
|
.replace(/{matricula}/g, aluno.enrollmentNumber || '—')
|
||||||
|
.replace(/{tipo_avaliacao}/g, tipoAvaliacao)
|
||||||
|
.replace(/{titulo_avaliacao}/g, exam.title)
|
||||||
|
.replace(/{materia}/g, materia)
|
||||||
|
.replace(/{escola}/g, appData.profile?.name || 'nossa escola');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `${evoConfig.apiUrl.replace(/\/$/, '')}/message/sendText/${evoConfig.instanceName}`;
|
||||||
|
await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'apikey': evoConfig.apiKey }, body: JSON.stringify({ number: cleanPhone, text: msg }) });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[Notificar Avaliação] Erro ${aluno.name}:`, error.message);
|
||||||
|
}
|
||||||
|
if (i < alunos.length - 1) await new Promise(r => setTimeout(r, Math.floor(Math.random() * 30000) + 15000));
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(200).json({ success: true, message: 'Notificações criadas e disparos iniciados.' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao notificar exames:', error);
|
||||||
|
return res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Notificações do Sistema (Painel Admin)
|
||||||
|
// ============================================================
|
||||||
|
app.get('/api/notificacoes/admin', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
'SELECT id, aluno_id as "studentId", titulo as title, mensagem as message, lida as read, anexo as attachment, created_at as "createdAt" FROM notificacoes WHERE aluno_id = $1 ORDER BY created_at DESC',
|
||||||
|
['admin']
|
||||||
|
);
|
||||||
|
res.json({ notifications: rows });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/notificacoes/ler/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
await pool.query('UPDATE notificacoes SET lida = true WHERE id = $1', [id]);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/notificacoes/limpar-lidas', async (req, res) => {
|
||||||
|
try {
|
||||||
|
await pool.query('DELETE FROM notificacoes WHERE aluno_id = $1 AND lida = true', ['admin']);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/notificacoes/remover-anexo/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
await pool.query('UPDATE notificacoes SET anexo = NULL WHERE id = $1', [id]);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Disparo em Massa
|
// Disparo em Massa
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
|
||||||
|
|
@ -366,7 +366,10 @@ export async function syncJsonToRelationalTables() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Sincronizar Disciplinas (Subjects)
|
// Garantir colunas de refação em provas
|
||||||
|
await client.query('ALTER TABLE provas ADD COLUMN IF NOT EXISTS permitir_refacao BOOLEAN DEFAULT FALSE');
|
||||||
|
|
||||||
|
// 1. Sincronizar Disciplinas (Subjects)
|
||||||
if (data.subjects && Array.isArray(data.subjects)) {
|
if (data.subjects && Array.isArray(data.subjects)) {
|
||||||
for (const sub of data.subjects) {
|
for (const sub of data.subjects) {
|
||||||
if (!sub.id || !sub.name) continue;
|
if (!sub.id || !sub.name) continue;
|
||||||
|
|
@ -447,12 +450,13 @@ export async function syncJsonToRelationalTables() {
|
||||||
for (const e of data.exams) {
|
for (const e of data.exams) {
|
||||||
if (!e.id || !e.title) continue;
|
if (!e.id || !e.title) continue;
|
||||||
await client.query(
|
await client.query(
|
||||||
`INSERT INTO provas (id, turma_id, disciplina_id, periodo_id, titulo, duracao_minutos, status)
|
`INSERT INTO provas (id, turma_id, disciplina_id, periodo_id, titulo, duracao_minutos, status, permitir_refacao)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
ON CONFLICT (id) DO UPDATE SET
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
turma_id = EXCLUDED.turma_id, disciplina_id = EXCLUDED.disciplina_id, periodo_id = EXCLUDED.periodo_id,
|
turma_id = EXCLUDED.turma_id, disciplina_id = EXCLUDED.disciplina_id, periodo_id = EXCLUDED.periodo_id,
|
||||||
titulo = EXCLUDED.titulo, duracao_minutos = EXCLUDED.duracao_minutos, status = EXCLUDED.status`,
|
titulo = EXCLUDED.titulo, duracao_minutos = EXCLUDED.duracao_minutos, status = EXCLUDED.status,
|
||||||
[e.id, e.classId || null, e.subjectId || null, e.periodId || null, e.title, e.durationMinutes || 60, e.status || 'draft']
|
permitir_refacao = EXCLUDED.permitir_refacao`,
|
||||||
|
[e.id, e.classId || null, e.subjectId || null, e.periodId || null, e.title, e.durationMinutes || 60, e.status || 'draft', e.allowRetake || false]
|
||||||
).catch(err => console.warn(`[Sync:Provas] Erro na prova ${e.id}:`, err.message));
|
).catch(err => console.warn(`[Sync:Provas] Erro na prova ${e.id}:`, err.message));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -339,22 +339,28 @@ app.post('/api/portal/frequencia/justificar', authMiddleware, upload.single('arq
|
||||||
recordIndex = attendance.length - 1;
|
recordIndex = attendance.length - 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
notifications.push({
|
// Inserir notificação para o ADMIN na tabela SQL
|
||||||
id: `notif-${Date.now()}`,
|
try {
|
||||||
studentId: 'admin',
|
await pool.query(
|
||||||
fromStudentId: req.user.studentId, // Identificador para navegação no Manager
|
`INSERT INTO notificacoes (aluno_id, titulo, mensagem, anexo, lida, created_at)
|
||||||
title: 'Nova Justificativa de Falta',
|
VALUES ($1, $2, $3, $4, $5, NOW())`,
|
||||||
message: JSON.stringify({
|
[
|
||||||
text: `${student?.name || 'Aluno'} enviou uma justificativa para a aula de ${date}.`,
|
'admin',
|
||||||
motivo: motivo.trim()
|
'Nova Justificativa de Falta',
|
||||||
}),
|
JSON.stringify({
|
||||||
attachment: publicUrl,
|
text: `${student?.name || 'Aluno'} enviou uma justificativa para a aula de ${date}.`,
|
||||||
read: false,
|
motivo: motivo.trim(),
|
||||||
createdAt: new Date().toISOString(),
|
fromStudentId: req.user.studentId
|
||||||
});
|
}),
|
||||||
|
publicUrl,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} catch (notifErr) {
|
||||||
|
console.error('[Portal:Justificação] Erro ao salvar notificação SQL:', notifErr.message);
|
||||||
|
}
|
||||||
|
|
||||||
schoolData.attendance = attendance;
|
schoolData.attendance = attendance;
|
||||||
schoolData.notifications = notifications;
|
|
||||||
schoolData.lastUpdated = new Date().toISOString();
|
schoolData.lastUpdated = new Date().toISOString();
|
||||||
await saveSchoolData(schoolData);
|
await saveSchoolData(schoolData);
|
||||||
|
|
||||||
|
|
@ -417,6 +423,16 @@ app.get('/api/portal/aulas', authMiddleware, async (req, res) => {
|
||||||
const student = (schoolData.students || []).find(s => s.id === req.user.studentId);
|
const student = (schoolData.students || []).find(s => s.id === req.user.studentId);
|
||||||
if (!student) return res.json({ lessons: [] });
|
if (!student) return res.json({ lessons: [] });
|
||||||
|
|
||||||
|
const { rows: turmasData } = await pool.query(
|
||||||
|
'SELECT DISTINCT turma_id FROM frequencias WHERE aluno_id = $1',
|
||||||
|
[req.user.studentId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const studentClassIds = new Set([
|
||||||
|
student.classId,
|
||||||
|
...turmasData.map(r => r.turma_id)
|
||||||
|
].filter(Boolean));
|
||||||
|
|
||||||
const parseDateHelper = (dStr) => {
|
const parseDateHelper = (dStr) => {
|
||||||
if (!dStr) return 0;
|
if (!dStr) return 0;
|
||||||
const parts = dStr.substring(0, 10).split(/[-/]/);
|
const parts = dStr.substring(0, 10).split(/[-/]/);
|
||||||
|
|
@ -426,7 +442,11 @@ app.get('/api/portal/aulas', authMiddleware, async (req, res) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const lessons = (schoolData.lessons || [])
|
const lessons = (schoolData.lessons || [])
|
||||||
.filter(l => l.classId === student.classId)
|
.filter(l => studentClassIds.has(l.classId))
|
||||||
|
.map(l => {
|
||||||
|
const classObj = (schoolData.classes || []).find(c => c.id === l.classId);
|
||||||
|
return { ...l, className: classObj ? classObj.name : 'Turma' };
|
||||||
|
})
|
||||||
.sort((a, b) => parseDateHelper(a.date) - parseDateHelper(b.date));
|
.sort((a, b) => parseDateHelper(a.date) - parseDateHelper(b.date));
|
||||||
|
|
||||||
res.json({ lessons });
|
res.json({ lessons });
|
||||||
|
|
@ -438,12 +458,16 @@ app.get('/api/portal/aulas', authMiddleware, async (req, res) => {
|
||||||
// GET /api/portal/notificacoes
|
// GET /api/portal/notificacoes
|
||||||
app.get('/api/portal/notificacoes', authMiddleware, async (req, res) => {
|
app.get('/api/portal/notificacoes', authMiddleware, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const schoolData = await getSchoolData();
|
const { rows } = await pool.query(
|
||||||
const notifications = (schoolData.notifications || [])
|
`SELECT id, titulo as title, mensagem as message, lida as read, anexo as attachment, created_at as "createdAt"
|
||||||
.filter(n => n.studentId === req.user.studentId)
|
FROM notificacoes
|
||||||
.sort((a, b) => new Date(b.createdAt || 0).getTime() - new Date(a.createdAt || 0).getTime());
|
WHERE aluno_id = $1
|
||||||
res.json({ notifications });
|
ORDER BY created_at DESC`,
|
||||||
|
[req.user.studentId]
|
||||||
|
);
|
||||||
|
res.json({ notifications: rows });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error('Erro ao buscar notificações:', err);
|
||||||
res.status(500).json({ error: 'Erro interno' });
|
res.status(500).json({ error: 'Erro interno' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -452,16 +476,13 @@ app.get('/api/portal/notificacoes', authMiddleware, async (req, res) => {
|
||||||
app.put('/api/portal/notificacoes/ler/:id', authMiddleware, async (req, res) => {
|
app.put('/api/portal/notificacoes/ler/:id', authMiddleware, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const schoolData = await getSchoolData();
|
await pool.query(
|
||||||
const notifications = schoolData.notifications || [];
|
'UPDATE notificacoes SET lida = true WHERE id = $1 AND aluno_id = $2',
|
||||||
const idx = notifications.findIndex(n => n.id === id && n.studentId === req.user.studentId);
|
[id, req.user.studentId]
|
||||||
if (idx === -1) return res.status(404).json({ error: 'Notificação não encontrada' });
|
);
|
||||||
notifications[idx] = { ...notifications[idx], read: true };
|
|
||||||
schoolData.notifications = notifications;
|
|
||||||
schoolData.lastUpdated = new Date().toISOString();
|
|
||||||
await saveSchoolData(schoolData);
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error('Erro ao marcar notificação como lida:', err);
|
||||||
res.status(500).json({ error: 'Erro interno' });
|
res.status(500).json({ error: 'Erro interno' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -470,14 +491,13 @@ app.put('/api/portal/notificacoes/ler/:id', authMiddleware, async (req, res) =>
|
||||||
app.delete('/api/portal/notificacoes/:id', authMiddleware, async (req, res) => {
|
app.delete('/api/portal/notificacoes/:id', authMiddleware, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const schoolData = await getSchoolData();
|
await pool.query(
|
||||||
schoolData.notifications = (schoolData.notifications || []).filter(
|
'DELETE FROM notificacoes WHERE id = $1 AND aluno_id = $2',
|
||||||
n => !(n.id === id && n.studentId === req.user.studentId)
|
[id, req.user.studentId]
|
||||||
);
|
);
|
||||||
schoolData.lastUpdated = new Date().toISOString();
|
|
||||||
await saveSchoolData(schoolData);
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error('Erro ao deletar notificação:', err);
|
||||||
res.status(500).json({ error: 'Erro interno' });
|
res.status(500).json({ error: 'Erro interno' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -178,6 +178,86 @@ export default function Avaliacoes() {
|
||||||
const getSubmission = (examId: string) =>
|
const getSubmission = (examId: string) =>
|
||||||
submissions.find(s => s.exam_id === examId);
|
submissions.find(s => s.exam_id === examId);
|
||||||
|
|
||||||
|
const renderAppModal = () => {
|
||||||
|
if (!showModal) return null;
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed', inset: 0, zIndex: 99999,
|
||||||
|
background: 'rgba(0,0,0,0.6)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
padding: '1rem',
|
||||||
|
}}>
|
||||||
|
<div className="glass-card animate-scale-in" style={{
|
||||||
|
maxWidth: 400, width: '100%', padding: '2rem', textAlign: 'center',
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: 56, height: 56, borderRadius: '50%', margin: '0 auto 1rem',
|
||||||
|
background: modalType === 'error' ? 'var(--bg-danger-alpha)' : modalType === 'confirm' ? 'var(--bg-warning-alpha)' : 'var(--bg-primary-alpha)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
{modalType === 'error'
|
||||||
|
? <XCircle size={28} color="var(--color-danger)" />
|
||||||
|
: modalType === 'confirm'
|
||||||
|
? <AlertTriangle size={28} color="var(--color-warning)" />
|
||||||
|
: modalType === 'loading'
|
||||||
|
? <RefreshCw size={28} color="var(--color-primary)" className="animate-spin" />
|
||||||
|
: <CheckCircle2 size={28} color="var(--color-primary)" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: '0.9rem', fontWeight: 500, marginBottom: modalType === 'loading' ? 0 : '1.5rem', lineHeight: 1.5 }}>
|
||||||
|
{modalMsg}
|
||||||
|
</p>
|
||||||
|
{modalType !== 'loading' && (
|
||||||
|
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'center' }}>
|
||||||
|
{modalType === 'confirm' ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowModal(false)}
|
||||||
|
style={{
|
||||||
|
flex: 1, padding: '0.65rem', borderRadius: 10,
|
||||||
|
border: '1px solid var(--glass-border)',
|
||||||
|
background: 'var(--color-surface-light)',
|
||||||
|
color: 'var(--color-text)', fontWeight: 600,
|
||||||
|
cursor: 'pointer', fontSize: '0.85rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowModal(false); confirmCallback?.(); }}
|
||||||
|
style={{
|
||||||
|
flex: 1, padding: '0.65rem', borderRadius: 10,
|
||||||
|
border: 'none',
|
||||||
|
background: 'var(--color-success)',
|
||||||
|
color: 'white', fontWeight: 700,
|
||||||
|
cursor: 'pointer', fontSize: '0.85rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Sim, Confirmar
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowModal(false)}
|
||||||
|
style={{
|
||||||
|
width: '100%', padding: '0.65rem', borderRadius: 10,
|
||||||
|
border: 'none',
|
||||||
|
background: 'var(--color-primary)',
|
||||||
|
color: 'white', fontWeight: 700,
|
||||||
|
cursor: 'pointer', fontSize: '0.85rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
OK
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// RENDER: Listing
|
// RENDER: Listing
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|
@ -190,6 +270,7 @@ export default function Avaliacoes() {
|
||||||
<div key={i} className="skeleton" style={{ height: 200, borderRadius: 16 }} />
|
<div key={i} className="skeleton" style={{ height: 200, borderRadius: 16 }} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{renderAppModal()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -444,83 +525,7 @@ export default function Avaliacoes() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* In-App Modal */}
|
{renderAppModal()}
|
||||||
{showModal && (
|
|
||||||
<div style={{
|
|
||||||
position: 'fixed', inset: 0, zIndex: 99999,
|
|
||||||
background: 'rgba(0,0,0,0.6)',
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
padding: '1rem',
|
|
||||||
}}>
|
|
||||||
<div className="glass-card animate-scale-in" style={{
|
|
||||||
maxWidth: 400, width: '100%', padding: '2rem', textAlign: 'center',
|
|
||||||
background: 'var(--color-surface)',
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
width: 56, height: 56, borderRadius: '50%', margin: '0 auto 1rem',
|
|
||||||
background: modalType === 'error' ? 'var(--bg-danger-alpha)' : modalType === 'confirm' ? 'var(--bg-warning-alpha)' : 'var(--bg-primary-alpha)',
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
}}>
|
|
||||||
{modalType === 'error'
|
|
||||||
? <XCircle size={28} color="var(--color-danger)" />
|
|
||||||
: modalType === 'confirm'
|
|
||||||
? <AlertTriangle size={28} color="var(--color-warning)" />
|
|
||||||
: modalType === 'loading'
|
|
||||||
? <RefreshCw size={28} color="var(--color-primary)" className="animate-spin" />
|
|
||||||
: <CheckCircle2 size={28} color="var(--color-primary)" />
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<p style={{ fontSize: '0.9rem', fontWeight: 500, marginBottom: modalType === 'loading' ? 0 : '1.5rem', lineHeight: 1.5 }}>
|
|
||||||
{modalMsg}
|
|
||||||
</p>
|
|
||||||
{modalType !== 'loading' && (
|
|
||||||
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'center' }}>
|
|
||||||
{modalType === 'confirm' ? (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowModal(false)}
|
|
||||||
style={{
|
|
||||||
flex: 1, padding: '0.65rem', borderRadius: 10,
|
|
||||||
border: '1px solid var(--glass-border)',
|
|
||||||
background: 'var(--color-surface-light)',
|
|
||||||
color: 'var(--color-text)', fontWeight: 600,
|
|
||||||
cursor: 'pointer', fontSize: '0.85rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancelar
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => { setShowModal(false); confirmCallback?.(); }}
|
|
||||||
style={{
|
|
||||||
flex: 1, padding: '0.65rem', borderRadius: 10,
|
|
||||||
border: 'none',
|
|
||||||
background: 'var(--color-success)',
|
|
||||||
color: 'white', fontWeight: 700,
|
|
||||||
cursor: 'pointer', fontSize: '0.85rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Sim, Finalizar
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={() => setShowModal(false)}
|
|
||||||
style={{
|
|
||||||
width: '100%', padding: '0.65rem', borderRadius: 10,
|
|
||||||
border: 'none',
|
|
||||||
background: 'var(--color-primary)',
|
|
||||||
color: 'white', fontWeight: 700,
|
|
||||||
cursor: 'pointer', fontSize: '0.85rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
OK
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -626,6 +631,7 @@ export default function Avaliacoes() {
|
||||||
<ArrowLeft size={18} /> Voltar às Atividades e Provas
|
<ArrowLeft size={18} /> Voltar às Atividades e Provas
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{renderAppModal()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -854,6 +860,7 @@ export default function Avaliacoes() {
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{renderAppModal()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -170,11 +170,29 @@ export default function Frequencia() {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Stats calculation (aligned with list logic)
|
// Stats calculation (UNIFIED with list logic)
|
||||||
const totalCourseLessons = lessons.length;
|
let presences = 0;
|
||||||
const presences = attendance.filter(a => a.type === 'presence').length;
|
let absences = 0;
|
||||||
const absences = attendance.filter(a => a.type === 'absence' && !a.justification).length;
|
let justified = 0;
|
||||||
const justified = attendance.filter(a => a.type === 'absence' && !!a.justification).length;
|
|
||||||
|
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 completedLessons = processedItems.filter(item => item.isCompleted && item.lesson.status !== 'cancelled').length;
|
||||||
const pendingLessons = 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 percentage = totalCourseLessons > 0 ? Math.round((presences / totalCourseLessons) * 100) : 0;
|
||||||
|
|
@ -405,6 +423,7 @@ export default function Frequencia() {
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Data</th>
|
<th>Data</th>
|
||||||
|
<th>Turma</th>
|
||||||
<th>Horário</th>
|
<th>Horário</th>
|
||||||
<th>Status de Aula</th>
|
<th>Status de Aula</th>
|
||||||
<th>Presença</th>
|
<th>Presença</th>
|
||||||
|
|
@ -436,6 +455,11 @@ export default function Frequencia() {
|
||||||
backgroundColor: isJustificationAccepted ? 'rgba(245, 158, 11, 0.08)' : 'transparent',
|
backgroundColor: isJustificationAccepted ? 'rgba(245, 158, 11, 0.08)' : 'transparent',
|
||||||
}}>
|
}}>
|
||||||
<td>{formatDateFull(lesson.date)}</td>
|
<td>{formatDateFull(lesson.date)}</td>
|
||||||
|
<td>
|
||||||
|
<span style={{ fontSize: '0.8125rem', color: 'var(--color-primary)', fontWeight: 600 }}>
|
||||||
|
{(lesson as any).className || '—'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{typeof lesson.startTime === 'string' ? (
|
{typeof lesson.startTime === 'string' ? (
|
||||||
<span style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)' }}>
|
<span style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)' }}>
|
||||||
|
|
@ -519,7 +543,7 @@ export default function Frequencia() {
|
||||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6, color: 'var(--color-info)', fontWeight: 500 }}>
|
<span style={{ display: 'flex', alignItems: 'center', gap: 6, color: 'var(--color-info)', fontWeight: 500 }}>
|
||||||
<Clock size={16} /> Justificativa Pendente
|
<Clock size={16} /> Justificativa Pendente
|
||||||
</span>
|
</span>
|
||||||
) : (isCompleted || parseLessonDateTime(lesson.date || '', '23:59:59') < now.getTime()) ? (
|
) : (isCompleted || parseLessonDateTime(lesson.date || '', '23:59:59') < now.getTime()) && !isCancelled ? (
|
||||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6, color: 'var(--color-danger)' }}>
|
<span style={{ display: 'flex', alignItems: 'center', gap: 6, color: 'var(--color-danger)' }}>
|
||||||
<XCircle size={16} /> Falta
|
<XCircle size={16} /> Falta
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue