feat: central de notificações profissionais (financeiro e acadêmico) e estabilização de frequência

This commit is contained in:
Sidney 2026-05-11 19:48:43 -03:00
parent f1b02f0337
commit 4c8ce88ca1
4 changed files with 170 additions and 37 deletions

View File

@ -1,5 +1,4 @@
import React, { useState, useEffect, useRef } from 'react'; import { Bell, X, CheckCircle, Trash2, ShieldCheck, FileText, Paperclip, DollarSign, AlertTriangle, Info, TrendingUp, CreditCard } from 'lucide-react';
import { Bell, X, CheckCircle, Trash2, ShieldCheck, FileText, Paperclip } from 'lucide-react';
import { SchoolData, Notification, View } from '../types'; import { SchoolData, Notification, View } from '../types';
import { dbService } from '../services/dbService'; import { dbService } from '../services/dbService';
@ -81,6 +80,21 @@ const AdminNotifications: React.FC<Props> = ({ data, updateData, setView, onNavi
const handleAction = (notif: Notification) => { const handleAction = (notif: Notification) => {
if (!notif.read) handleMarkAsRead(notif.id); if (!notif.read) handleMarkAsRead(notif.id);
const isFinance = notif.title.toLowerCase().includes('pagamento') || notif.title.toLowerCase().includes('cobrança');
const isExam = notif.title.toLowerCase().includes('prova') || notif.title.toLowerCase().includes('atividade');
if (isFinance) {
setView(View.Finance);
setIsOpen(false);
return;
}
if (isExam) {
setView(View.ReportCard);
setIsOpen(false);
return;
}
if (notif.title.toLowerCase().includes('justificativa') || notif.message.toLowerCase().includes('justificativa')) { if (notif.title.toLowerCase().includes('justificativa') || notif.message.toLowerCase().includes('justificativa')) {
const targetId = (notif as any).fromStudentId || notif.studentId; const targetId = (notif as any).fromStudentId || notif.studentId;
if (onNavigateToStudent && targetId !== 'admin') { if (onNavigateToStudent && targetId !== 'admin') {
@ -164,7 +178,7 @@ const AdminNotifications: React.FC<Props> = ({ data, updateData, setView, onNavi
<div className="absolute top-14 right-0 w-80 sm:w-96 bg-white rounded-2xl shadow-2xl border border-slate-200 overflow-hidden animate-in slide-in-from-top-4 fade-in duration-200 flex flex-col max-h-[80vh]"> <div className="absolute top-14 right-0 w-80 sm:w-96 bg-white rounded-2xl shadow-2xl border border-slate-200 overflow-hidden animate-in slide-in-from-top-4 fade-in duration-200 flex flex-col max-h-[80vh]">
<div className="p-4 bg-slate-50 border-b border-slate-200 flex items-center justify-between sticky top-0 z-10"> <div className="p-4 bg-slate-50 border-b border-slate-200 flex items-center justify-between sticky top-0 z-10">
<div> <div>
<h3 className="font-black text-slate-800 flex items-center gap-2">Atividades/Provas Pendentes <h3 className="font-black text-slate-800 flex items-center gap-2">Central de Alertas
{unreadCount > 0 && <span className="bg-indigo-100 text-indigo-700 px-2 py-0.5 rounded-full text-[10px] font-bold">{unreadCount}</span>} {unreadCount > 0 && <span className="bg-indigo-100 text-indigo-700 px-2 py-0.5 rounded-full text-[10px] font-bold">{unreadCount}</span>}
</h3> </h3>
</div> </div>
@ -193,6 +207,7 @@ const AdminNotifications: React.FC<Props> = ({ data, updateData, setView, onNavi
let displayMessage = notif.message; let displayMessage = notif.message;
let justificationMotive = ''; let justificationMotive = '';
let attachmentFromMessage = null; let attachmentFromMessage = null;
let metadata: any = {};
if (notif.message.startsWith('{')) { if (notif.message.startsWith('{')) {
try { try {
@ -203,20 +218,64 @@ const AdminNotifications: React.FC<Props> = ({ data, updateData, setView, onNavi
} catch(e) {} } catch(e) {}
} }
if (notif.anexo && notif.anexo.startsWith('{')) {
try { metadata = JSON.parse(notif.anexo); } catch(e) {}
}
const finalAttachment = notif.attachment || attachmentFromMessage; const finalAttachment = notif.attachment || attachmentFromMessage;
const isFinance = metadata.type === 'finance' || notif.title.toLowerCase().includes('pagamento') || notif.title.toLowerCase().includes('cobrança');
const isExam = metadata.type === 'exam' || notif.title.toLowerCase().includes('prova') || notif.title.toLowerCase().includes('atividade');
// Configuração dinâmica de cores e ícones
let icon = <Info size={16} />;
let colorClass = 'text-indigo-500';
let bgClass = 'bg-indigo-50';
let borderClass = 'border-indigo-100';
if (isFinance) {
if (metadata.status === 'paid' || notif.title.includes('Confirmado')) {
icon = <DollarSign size={16} />;
colorClass = 'text-emerald-500';
bgClass = 'bg-emerald-50';
borderClass = 'border-emerald-100';
} else if (metadata.status === 'overdue' || notif.title.includes('Atraso')) {
icon = <AlertTriangle size={16} />;
colorClass = 'text-red-500';
bgClass = 'bg-red-50';
borderClass = 'border-red-100';
} else {
icon = <TrendingUp size={16} />;
colorClass = 'text-blue-500';
bgClass = 'bg-blue-50';
borderClass = 'border-blue-100';
}
} else if (isExam) {
icon = <ClipboardList size={16} />;
colorClass = 'text-violet-600';
bgClass = 'bg-violet-50';
borderClass = 'border-violet-100';
} else if (isJustificativa) {
icon = <FileText size={16} />;
colorClass = 'text-amber-500';
bgClass = 'bg-amber-50';
borderClass = 'border-amber-100';
}
return ( return (
<div key={notif.id} onClick={() => handleAction(notif)} className={`p-3 rounded-xl border transition-all cursor-pointer relative overflow-hidden group ${notif.read ? 'bg-slate-50 border-transparent opacity-70' : 'bg-white border-indigo-100 hover:border-indigo-300 shadow-sm'}`}> <div key={notif.id} onClick={() => handleAction(notif)} className={`p-3 rounded-xl border transition-all cursor-pointer relative overflow-hidden group ${notif.read ? 'bg-slate-50 border-transparent opacity-70' : `bg-white ${borderClass} hover:shadow-md shadow-sm`}`}>
{!notif.read && <div className="absolute left-0 top-0 bottom-0 w-1 bg-indigo-500"></div>} {!notif.read && <div className={`absolute left-0 top-0 bottom-0 w-1 ${colorClass.replace('text-', 'bg-')}`}></div>}
<div className="flex justify-between items-start mb-1 gap-4"> <div className="flex justify-between items-start mb-1 gap-4">
<h4 className={`text-base font-black tracking-tight ${notif.read ? 'text-slate-400' : 'text-emerald-500 animate-pulse'}`}> <div className="flex items-center gap-2">
{notif.title} <span className={`${colorClass} ${bgClass} p-1 rounded-md`}>{icon}</span>
</h4> <h4 className={`text-sm font-black tracking-tight ${notif.read ? 'text-slate-400' : 'text-slate-800'}`}>
<span className={`text-[10px] font-bold whitespace-nowrap px-2 py-1 rounded ${notif.read ? 'bg-slate-100 text-slate-400' : 'bg-emerald-50 text-emerald-600 border border-emerald-100'}`}> {notif.title}
</h4>
</div>
<span className={`text-[10px] font-bold whitespace-nowrap px-2 py-1 rounded ${notif.read ? 'bg-slate-100 text-slate-400' : `${bgClass} ${colorClass} border ${borderClass}`}`}>
{new Date(notif.createdAt).toLocaleDateString('pt-BR')} {new Date(notif.createdAt).toLocaleDateString('pt-BR')}
</span> </span>
</div> </div>
<p className={`text-sm font-medium leading-relaxed mb-2 ${notif.read ? 'text-slate-400' : 'text-emerald-600/90'}`}> <p className={`text-xs font-medium leading-relaxed mb-2 ${notif.read ? 'text-slate-400' : 'text-slate-600'}`}>
{displayMessage} {displayMessage}
</p> </p>
{isJustificativa && justificationMotive && ( {isJustificativa && justificationMotive && (

View File

@ -56,9 +56,20 @@ const lockCache = new Set();
let activeCronJob = null; // Referência global para o agendamento preventivo let activeCronJob = null; // Referência global para o agendamento preventivo
let activeCronJobOverdue = null; // Referência global para o agendamento de inadimplência let activeCronJobOverdue = null; // Referência global para o agendamento de inadimplência
// ============================================================ // === Funções Auxiliares de Notificação ===
async function createAdminNotification(titulo, mensagem, metadata = {}) {
try {
await pool.query(
'INSERT INTO notificacoes (aluno_id, titulo, mensagem, anexo) VALUES ($1, $2, $3, $4)',
['admin', titulo, mensagem, JSON.stringify(metadata)]
);
console.log(`[Notification] Alerta Admin criado: ${titulo}`);
} catch (err) {
console.error('[Notification] Erro ao criar alerta admin:', err.message);
}
}
// Proxy de Imagens do MinIO (acesso público via backend) // Proxy de Imagens do MinIO (acesso público via backend)
// ============================================================
app.get(/^\/storage\/([^\/]+)\/(.+)$/, async (req, res) => { app.get(/^\/storage\/([^\/]+)\/(.+)$/, async (req, res) => {
try { try {
const bucket = req.params[0]; const bucket = req.params[0];
@ -724,6 +735,7 @@ app.post('/api/webhook_asaas', async (req, res) => {
const asaasPaymentId = payload.payment.id; const asaasPaymentId = payload.payment.id;
let updateData = {}; let updateData = {};
const targetName = payload.payment.customerName || 'Cliente';
switch (payload.event) { switch (payload.event) {
case 'PAYMENT_CREATED': case 'PAYMENT_CREATED':
@ -740,23 +752,48 @@ app.post('/api/webhook_asaas', async (req, res) => {
if (payload.payment.transactionReceiptUrl) { if (payload.payment.transactionReceiptUrl) {
updateData.transaction_receipt_url = payload.payment.transactionReceiptUrl; updateData.transaction_receipt_url = payload.payment.transactionReceiptUrl;
} }
// Chamada única: sendEvolutionMessage já possui trava interna de cache por ID de pagamento
// Alerta no Sino (Admin)
createAdminNotification(
'✅ Pagamento Confirmado',
`Recebemos R$ ${Number(payload.payment.value).toFixed(2)} de ${targetName}.`,
{ type: 'finance', status: 'paid', paymentId: asaasPaymentId }
);
sendEvolutionMessage(asaasPaymentId, 'PAYMENT_RECEIVED'); sendEvolutionMessage(asaasPaymentId, 'PAYMENT_RECEIVED');
break; break;
case 'PAYMENT_OVERDUE': case 'PAYMENT_OVERDUE':
updateData = { status: 'ATRASADO' };
// Alerta no Sino (Admin)
createAdminNotification(
'⚠️ Pagamento em Atraso',
`A cobrança de ${targetName} no valor de R$ ${Number(payload.payment.value).toFixed(2)} está vencida.`,
{ type: 'finance', status: 'overdue', paymentId: asaasPaymentId }
);
sendEvolutionMessage(asaasPaymentId, 'PAYMENT_OVERDUE');
break;
case 'PAYMENT_UPDATED': case 'PAYMENT_UPDATED':
case 'PAYMENT_RESTORED': // Alerta no Sino (Admin)
const statusMap = { 'PENDING': 'PENDENTE', 'OVERDUE': 'ATRASADO', 'RECEIVED': 'PAGO', 'CONFIRMED': 'PAGO', 'RECEIVED_IN_CASH': 'PAGO', 'REFUNDED': 'CANCELADO', 'DELETED': 'CANCELADO' }; createAdminNotification(
updateData = { valor: payload.payment.value, vencimento: payload.payment.dueDate, status: statusMap[payload.payment.status] || undefined }; '📝 Cobrança Alterada',
Object.keys(updateData).forEach(k => updateData[k] === undefined && delete updateData[k]); `A cobrança de ${targetName} foi atualizada no Asaas.`,
// Ocultado PAYMENT_OVERDUE aqui para ser enviado apenas pela rotina/cron (conforme regras) { type: 'finance', status: 'updated', paymentId: asaasPaymentId }
// if (payload.event === 'PAYMENT_OVERDUE') sendEvolutionMessage(asaasPaymentId, 'PAYMENT_OVERDUE'); );
if (payload.event === 'PAYMENT_UPDATED') sendEvolutionMessage(asaasPaymentId, 'PAYMENT_UPDATED'); if (payload.event === 'PAYMENT_UPDATED') sendEvolutionMessage(asaasPaymentId, 'PAYMENT_UPDATED');
break; break;
case 'PAYMENT_DELETED': case 'PAYMENT_DELETED':
case 'PAYMENT_CANCELED': case 'PAYMENT_CANCELED':
// Alerta no Sino (Admin)
createAdminNotification(
'🗑️ Cobrança Removida',
`A cobrança de ${targetName} (R$ ${Number(payload.payment.value).toFixed(2)}) foi excluída no Asaas.`,
{ type: 'finance', status: 'deleted', paymentId: asaasPaymentId }
);
const installmentId = payload.payment.installment; const installmentId = payload.payment.installment;
if (installmentId) { if (installmentId) {
if (cancelCache.has(installmentId)) { if (cancelCache.has(installmentId)) {

View File

@ -616,13 +616,46 @@ app.post('/api/portal/avaliacoes/submeter', authMiddleware, async (req, res) =>
// Integrar com notas_boletim (Nova Tabela) em vez de school_data // Integrar com notas_boletim (Nova Tabela) em vez de school_data
if (exam.subjectId && exam.periodId) { if (exam.subjectId && exam.periodId) {
await pool.query( try {
`INSERT INTO notas_boletim (aluno_id, disciplina_id, periodo_id, prova_id, valor, updated_at) await pool.query(
VALUES ($1, $2, $3, $4, $5, NOW()) `INSERT INTO notas_boletim (aluno_id, disciplina_id, periodo_id, prova_id, valor, updated_at)
ON CONFLICT (aluno_id, disciplina_id, periodo_id, prova_id) VALUES ($1, $2, $3, $4, $5, NOW())
DO UPDATE SET valor = EXCLUDED.valor, updated_at = NOW()`, ON CONFLICT (aluno_id, disciplina_id, periodo_id, prova_id)
[req.user.studentId, exam.subjectId, exam.periodId, examId, finalScore] DO UPDATE SET valor = EXCLUDED.valor, updated_at = NOW()`,
[req.user.studentId, exam.subjectId, exam.periodId, examId, finalScore]
);
} catch (gradeErr) {
console.error('[Portal:Submissão] Erro ao salvar nota no boletim:', gradeErr.message);
}
}
// Inserir notificação para o ADMIN no Sino
try {
const { rows: info } = await pool.query(
`SELECT a.nome as student_name, t.nome as class_name
FROM alunos a
LEFT JOIN turmas t ON a.turma_id = t.id
WHERE a.id = $1`,
[req.user.studentId]
); );
const studentName = info[0]?.student_name || req.user.name || 'Aluno';
const className = info[0]?.class_name || 'Turma não identificada';
const typeLabel = (exam as any).evaluationType === 'activity' ? 'Atividade' : 'Prova';
await pool.query(
`INSERT INTO notificacoes (aluno_id, titulo, mensagem, anexo, lida, created_at)
VALUES ($1, $2, $3, $4, $5, NOW())`,
[
'admin',
`📝 ${typeLabel} Finalizada`,
`${studentName} (${className}) finalizou a ${typeLabel.toLowerCase()} "${exam.title}" com nota ${finalScore}.`,
JSON.stringify({ type: 'exam', studentId: req.user.studentId, examId, grade: finalScore }),
false
]
);
} catch (notifErr) {
console.error('[Portal:Submissão] Erro ao disparar notificação admin:', notifErr.message);
} }
res.json({ success: true, result: { total_questions: totalQuestions, correct_count: correctCount, wrong_count: wrongCount, percentage, final_score: finalScore } }); res.json({ success: true, result: { total_questions: totalQuestions, correct_count: correctCount, wrong_count: wrongCount, percentage, final_score: finalScore } });

View File

@ -188,20 +188,20 @@ export default function Frequencia() {
let justified = 0; let justified = 0;
processedItems.forEach(item => { processedItems.forEach(item => {
const { lesson, attendances: atts } = item; const { lesson, attendances: atts, isCompleted } = item;
if (lesson.status === 'cancelled') return; if (lesson.status === 'cancelled') return;
const record = atts[0]; const record = atts[0];
const lessonEnd = new Date(lesson.date + 'T' + (lesson.endTime || '23:59') + ':00');
if (record) { if (record) {
if (record.type === 'absence') { if (record.type === 'absence') {
if (record.justificationAccepted) justified++; if (record.justificationAccepted) justified++;
else absences++; else absences++;
} else if (record.type === 'presence' || (!record.type && !(record as any).isVirtual)) { } else if (record.type === 'presence' || (!record.type && !(record as any).isVirtual)) {
// No portal, só contamos como presença nas estatísticas se a aula terminou ou se já foi marcada
presences++; presences++;
} }
} else if (now > lessonEnd) { } else if (isCompleted) {
absences++; absences++;
} }
}); });
@ -232,18 +232,18 @@ export default function Frequencia() {
// Check window (uses new 24h before/after logic) // Check window (uses new 24h before/after logic)
if (!isLessonWithinJustificationWindow(l, now)) return false; if (!isLessonWithinJustificationWindow(l, now)) return false;
// Construir janela como o Manager // Usar parseLessonDateTime para evitar bugs de fuso horário
const lessonStart = new Date(l.date + 'T' + (l.startTime || '00:00') + ':00'); const lessonStartMs = parseLessonDateTime(l.date, l.startTime || '00:00', 0);
const lessonEnd = new Date(l.date + 'T' + (l.endTime || '23:59') + ':00'); const lessonEndMs = parseLessonDateTime(l.date, l.endTime || '23:59', 23);
const presenceStartWindow = new Date(lessonStart.getTime() - 30 * 60 * 1000); const presenceStartWindowMs = lessonStartMs - (30 * 60 * 1000);
// Find if THIS SPECIFIC lesson has attendance/justification // Find if THIS SPECIFIC lesson has attendance/justification
const att = attendance.find(a => { const att = attendance.find(a => {
if (!a.date || typeof a.date !== 'string') return false; if (!a.date || typeof a.date !== 'string') return false;
if ((a as any).lessonId === l.id) return true; if ((a as any).lessonId === l.id) return true;
if (a.date === `${l.date}T${l.startTime || '00:00'}:00`) return true;
const recordTime = new Date(a.date); const recordTime = new Date(a.date).getTime();
return recordTime >= presenceStartWindow && recordTime <= lessonEnd; return recordTime >= presenceStartWindowMs && recordTime <= lessonEndMs;
}); });
if (att) { if (att) {
@ -547,6 +547,10 @@ export default function Frequencia() {
<span style={{ display: 'flex', alignItems: 'center', gap: 6, color: 'var(--color-success)' }}> <span style={{ display: 'flex', alignItems: 'center', gap: 6, color: 'var(--color-success)' }}>
<CheckCircle2 size={16} /> Presente <CheckCircle2 size={16} /> Presente
</span> </span>
) : (!isCompleted && !isCancelled) ? (
<span style={{ color: 'var(--color-text-secondary)', fontSize: '0.85rem', display: 'flex', alignItems: 'center', gap: 6 }}>
<Clock size={16} className="animate-pulse" /> Aguardando Presença
</span>
) : isJustificationAccepted ? ( ) : isJustificationAccepted ? (
<span style={{ display: 'flex', alignItems: 'center', gap: 6, color: '#f59e0b', fontWeight: 600 }}> <span style={{ display: 'flex', alignItems: 'center', gap: 6, color: '#f59e0b', fontWeight: 600 }}>
<AlertTriangle size={16} /> Falta Justificada <AlertTriangle size={16} /> Falta Justificada
@ -555,13 +559,13 @@ export default function Frequencia() {
<span style={{ display: 'flex', alignItems: 'center', gap: 6, color: '#f59e0b', fontWeight: 500 }}> <span style={{ display: 'flex', alignItems: 'center', gap: 6, color: '#f59e0b', fontWeight: 500 }}>
<Clock size={16} /> Justificativa Pendente <Clock size={16} /> Justificativa Pendente
</span> </span>
) : (isCompleted || parseLessonDateTime(lesson.date || '', '23:59:59') < now.getTime()) && !isCancelled ? ( ) : isCompleted && !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>
) : ( ) : (
<span style={{ color: 'var(--color-text-secondary)', fontSize: '0.85rem' }}> <span style={{ color: 'var(--color-text-secondary)', fontSize: '0.85rem' }}>
Aguardando
</span> </span>
)} )}
</td> </td>