feat: central de notificações profissionais (financeiro e acadêmico) e estabilização de frequência
This commit is contained in:
parent
f1b02f0337
commit
4c8ce88ca1
|
|
@ -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<Props> = ({ 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<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="p-4 bg-slate-50 border-b border-slate-200 flex items-center justify-between sticky top-0 z-10">
|
||||
<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>}
|
||||
</h3>
|
||||
</div>
|
||||
|
|
@ -193,6 +207,7 @@ const AdminNotifications: React.FC<Props> = ({ 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<Props> = ({ 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 = <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 (
|
||||
<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'}`}>
|
||||
{!notif.read && <div className="absolute left-0 top-0 bottom-0 w-1 bg-indigo-500"></div>}
|
||||
<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 ${colorClass.replace('text-', 'bg-')}`}></div>}
|
||||
<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">
|
||||
<span className={`${colorClass} ${bgClass} p-1 rounded-md`}>{icon}</span>
|
||||
<h4 className={`text-sm font-black tracking-tight ${notif.read ? 'text-slate-400' : 'text-slate-800'}`}>
|
||||
{notif.title}
|
||||
</h4>
|
||||
<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'}`}>
|
||||
</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')}
|
||||
</span>
|
||||
</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}
|
||||
</p>
|
||||
{isJustificativa && justificationMotive && (
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -616,6 +616,7 @@ 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) {
|
||||
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())
|
||||
|
|
@ -623,6 +624,38 @@ app.post('/api/portal/avaliacoes/submeter', authMiddleware, async (req, res) =>
|
|||
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 } });
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<span style={{ display: 'flex', alignItems: 'center', gap: 6, color: 'var(--color-success)' }}>
|
||||
<CheckCircle2 size={16} /> Presente
|
||||
</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 ? (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6, color: '#f59e0b', fontWeight: 600 }}>
|
||||
<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 }}>
|
||||
<Clock size={16} /> Justificativa Pendente
|
||||
</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)' }}>
|
||||
<XCircle size={16} /> Falta
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--color-text-secondary)', fontSize: '0.85rem' }}>
|
||||
Aguardando
|
||||
—
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
|
|
|
|||
Loading…
Reference in New Issue