diff --git a/manager/components/AdminNotifications.tsx b/manager/components/AdminNotifications.tsx index b490b43..227588c 100644 --- a/manager/components/AdminNotifications.tsx +++ b/manager/components/AdminNotifications.tsx @@ -1,5 +1,4 @@ -import React, { useState, useEffect, useRef } from 'react'; -import { Bell, X, CheckCircle, Trash2, ShieldCheck, FileText, Paperclip } from 'lucide-react'; +import { Bell, X, CheckCircle, Trash2, ShieldCheck, FileText, Paperclip, DollarSign, AlertTriangle, Info, TrendingUp, CreditCard } from 'lucide-react'; import { SchoolData, Notification, View } from '../types'; import { dbService } from '../services/dbService'; @@ -81,6 +80,21 @@ const AdminNotifications: React.FC = ({ data, updateData, setView, onNavi const handleAction = (notif: Notification) => { 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')) { const targetId = (notif as any).fromStudentId || notif.studentId; if (onNavigateToStudent && targetId !== 'admin') { @@ -164,7 +178,7 @@ const AdminNotifications: React.FC = ({ data, updateData, setView, onNavi
-

Atividades/Provas Pendentes +

Central de Alertas {unreadCount > 0 && {unreadCount}}

@@ -193,6 +207,7 @@ const AdminNotifications: React.FC = ({ data, updateData, setView, onNavi let displayMessage = notif.message; let justificationMotive = ''; let attachmentFromMessage = null; + let metadata: any = {}; if (notif.message.startsWith('{')) { try { @@ -203,20 +218,64 @@ const AdminNotifications: React.FC = ({ data, updateData, setView, onNavi } catch(e) {} } + if (notif.anexo && notif.anexo.startsWith('{')) { + try { metadata = JSON.parse(notif.anexo); } catch(e) {} + } + 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 = ; + 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 = ; + colorClass = 'text-emerald-500'; + bgClass = 'bg-emerald-50'; + borderClass = 'border-emerald-100'; + } else if (metadata.status === 'overdue' || notif.title.includes('Atraso')) { + icon = ; + colorClass = 'text-red-500'; + bgClass = 'bg-red-50'; + borderClass = 'border-red-100'; + } else { + icon = ; + colorClass = 'text-blue-500'; + bgClass = 'bg-blue-50'; + borderClass = 'border-blue-100'; + } + } else if (isExam) { + icon = ; + colorClass = 'text-violet-600'; + bgClass = 'bg-violet-50'; + borderClass = 'border-violet-100'; + } else if (isJustificativa) { + icon = ; + colorClass = 'text-amber-500'; + bgClass = 'bg-amber-50'; + borderClass = 'border-amber-100'; + } return ( -
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'}`}> - {!notif.read &&
} +
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 &&
}
-

- {notif.title} -

- +
+ {icon} +

+ {notif.title} +

+
+ {new Date(notif.createdAt).toLocaleDateString('pt-BR')}
-

+

{displayMessage}

{isJustificativa && justificationMotive && ( diff --git a/manager/server.selfhosted.js b/manager/server.selfhosted.js index 53d7c53..10657c4 100644 --- a/manager/server.selfhosted.js +++ b/manager/server.selfhosted.js @@ -56,9 +56,20 @@ const lockCache = new Set(); let activeCronJob = null; // Referência global para o agendamento preventivo 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) -// ============================================================ app.get(/^\/storage\/([^\/]+)\/(.+)$/, async (req, res) => { try { const bucket = req.params[0]; @@ -724,6 +735,7 @@ app.post('/api/webhook_asaas', async (req, res) => { const asaasPaymentId = payload.payment.id; let updateData = {}; + const targetName = payload.payment.customerName || 'Cliente'; switch (payload.event) { case 'PAYMENT_CREATED': @@ -740,23 +752,48 @@ app.post('/api/webhook_asaas', async (req, res) => { if (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'); break; 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_RESTORED': - const statusMap = { 'PENDING': 'PENDENTE', 'OVERDUE': 'ATRASADO', 'RECEIVED': 'PAGO', 'CONFIRMED': 'PAGO', 'RECEIVED_IN_CASH': 'PAGO', 'REFUNDED': 'CANCELADO', 'DELETED': 'CANCELADO' }; - updateData = { valor: payload.payment.value, vencimento: payload.payment.dueDate, status: statusMap[payload.payment.status] || undefined }; - Object.keys(updateData).forEach(k => updateData[k] === undefined && delete updateData[k]); - // Ocultado PAYMENT_OVERDUE aqui para ser enviado apenas pela rotina/cron (conforme regras) - // if (payload.event === 'PAYMENT_OVERDUE') sendEvolutionMessage(asaasPaymentId, 'PAYMENT_OVERDUE'); + // Alerta no Sino (Admin) + createAdminNotification( + '📝 Cobrança Alterada', + `A cobrança de ${targetName} foi atualizada no Asaas.`, + { type: 'finance', status: 'updated', paymentId: asaasPaymentId } + ); if (payload.event === 'PAYMENT_UPDATED') sendEvolutionMessage(asaasPaymentId, 'PAYMENT_UPDATED'); break; case 'PAYMENT_DELETED': 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; if (installmentId) { if (cancelCache.has(installmentId)) { diff --git a/portal/server.selfhosted.js b/portal/server.selfhosted.js index df74dc3..5c49832 100644 --- a/portal/server.selfhosted.js +++ b/portal/server.selfhosted.js @@ -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 if (exam.subjectId && exam.periodId) { - await pool.query( - `INSERT INTO notas_boletim (aluno_id, disciplina_id, periodo_id, prova_id, valor, updated_at) - VALUES ($1, $2, $3, $4, $5, NOW()) - ON CONFLICT (aluno_id, disciplina_id, periodo_id, prova_id) - DO UPDATE SET valor = EXCLUDED.valor, updated_at = NOW()`, - [req.user.studentId, exam.subjectId, exam.periodId, examId, finalScore] + try { + await pool.query( + `INSERT INTO notas_boletim (aluno_id, disciplina_id, periodo_id, prova_id, valor, updated_at) + VALUES ($1, $2, $3, $4, $5, NOW()) + ON CONFLICT (aluno_id, disciplina_id, periodo_id, prova_id) + 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 } }); diff --git a/portal/src/pages/Frequencia.tsx b/portal/src/pages/Frequencia.tsx index 1d181fe..fc00756 100644 --- a/portal/src/pages/Frequencia.tsx +++ b/portal/src/pages/Frequencia.tsx @@ -188,20 +188,20 @@ export default function Frequencia() { let justified = 0; processedItems.forEach(item => { - const { lesson, attendances: atts } = item; + const { lesson, attendances: atts, isCompleted } = item; if (lesson.status === 'cancelled') return; const record = atts[0]; - const lessonEnd = new Date(lesson.date + 'T' + (lesson.endTime || '23:59') + ':00'); if (record) { if (record.type === 'absence') { if (record.justificationAccepted) justified++; else absences++; } 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++; } - } else if (now > lessonEnd) { + } else if (isCompleted) { absences++; } }); @@ -232,18 +232,18 @@ export default function Frequencia() { // Check window (uses new 24h before/after logic) if (!isLessonWithinJustificationWindow(l, now)) return false; - // Construir janela como o Manager - const lessonStart = new Date(l.date + 'T' + (l.startTime || '00:00') + ':00'); - const lessonEnd = new Date(l.date + 'T' + (l.endTime || '23:59') + ':00'); - const presenceStartWindow = new Date(lessonStart.getTime() - 30 * 60 * 1000); + // Usar parseLessonDateTime para evitar bugs de fuso horário + const lessonStartMs = parseLessonDateTime(l.date, l.startTime || '00:00', 0); + const lessonEndMs = parseLessonDateTime(l.date, l.endTime || '23:59', 23); + const presenceStartWindowMs = lessonStartMs - (30 * 60 * 1000); // Find if THIS SPECIFIC lesson has attendance/justification const att = attendance.find(a => { if (!a.date || typeof a.date !== 'string') return false; 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); - return recordTime >= presenceStartWindow && recordTime <= lessonEnd; + + const recordTime = new Date(a.date).getTime(); + return recordTime >= presenceStartWindowMs && recordTime <= lessonEndMs; }); if (att) { @@ -547,6 +547,10 @@ export default function Frequencia() { Presente + ) : (!isCompleted && !isCancelled) ? ( + + Aguardando Presença + ) : isJustificationAccepted ? ( Falta Justificada @@ -555,13 +559,13 @@ export default function Frequencia() { Justificativa Pendente - ) : (isCompleted || parseLessonDateTime(lesson.date || '', '23:59:59') < now.getTime()) && !isCancelled ? ( + ) : isCompleted && !isCancelled ? ( Falta ) : ( - Aguardando + — )}