379 lines
18 KiB
TypeScript
379 lines
18 KiB
TypeScript
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';
|
|
|
|
interface Props {
|
|
data: SchoolData;
|
|
updateData: (newData: Partial<SchoolData>) => void;
|
|
setView: (view: View) => void;
|
|
onNavigateToStudent?: (studentId: string) => void;
|
|
}
|
|
|
|
const AdminNotifications: React.FC<Props> = ({ data, updateData, setView, onNavigateToStudent }) => {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [viewingAttachment, setViewingAttachment] = useState<string | null>(null);
|
|
const [notifWithAttachment, setNotifWithAttachment] = useState<Notification | null>(null);
|
|
const [adminNotifs, setAdminNotifs] = useState<Notification[]>([]);
|
|
const prevCountRef = useRef<number>(0);
|
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
|
|
|
const handleDeleteAttachment = async () => {
|
|
if (!notifWithAttachment) return;
|
|
try {
|
|
const resp = await fetch(`/api/notificacoes/remover-anexo/${notifWithAttachment.id}`, { method: 'PUT' });
|
|
if (resp.ok) {
|
|
setAdminNotifs(prev => prev.map(n => n.id === notifWithAttachment.id ? { ...n, attachment: undefined } : n));
|
|
setViewingAttachment(null);
|
|
setNotifWithAttachment(null);
|
|
}
|
|
} catch (e) {
|
|
console.error('Erro ao excluir anexo:', e);
|
|
}
|
|
};
|
|
|
|
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;
|
|
|
|
// Som de notificação quando chega uma nova
|
|
useEffect(() => {
|
|
if (unreadCount > prevCountRef.current && prevCountRef.current >= 0) {
|
|
try {
|
|
if (!audioRef.current) {
|
|
const ctx = new (window.AudioContext || (window as any).webkitAudioContext)();
|
|
const oscillator = ctx.createOscillator();
|
|
const gainNode = ctx.createGain();
|
|
oscillator.connect(gainNode);
|
|
gainNode.connect(ctx.destination);
|
|
oscillator.type = 'sine';
|
|
oscillator.frequency.setValueAtTime(880, ctx.currentTime);
|
|
oscillator.frequency.setValueAtTime(1100, ctx.currentTime + 0.1);
|
|
oscillator.frequency.setValueAtTime(880, ctx.currentTime + 0.2);
|
|
gainNode.gain.setValueAtTime(0.3, ctx.currentTime);
|
|
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.4);
|
|
oscillator.start(ctx.currentTime);
|
|
oscillator.stop(ctx.currentTime + 0.4);
|
|
}
|
|
} catch(e) {
|
|
console.warn('Som de notificação indisponível', e);
|
|
}
|
|
}
|
|
prevCountRef.current = unreadCount;
|
|
}, [unreadCount]);
|
|
|
|
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') {
|
|
onNavigateToStudent(targetId);
|
|
} else {
|
|
setView(View.AttendanceQuery);
|
|
}
|
|
setIsOpen(false);
|
|
}
|
|
};
|
|
|
|
const handleMarkAsRead = async (id: string) => {
|
|
try {
|
|
const resp = await fetch(`/api/notificacoes/ler/${id}`, { method: 'PUT' });
|
|
if (resp.ok) {
|
|
setAdminNotifs(prev => prev.map(n => n.id === id ? { ...n, read: true } : n));
|
|
}
|
|
} catch (e) {
|
|
console.error('Erro ao marcar como lida:', e);
|
|
}
|
|
};
|
|
|
|
const handleClearRead = async () => {
|
|
try {
|
|
const resp = await fetch('/api/notificacoes/limpar-lidas', { method: 'DELETE' });
|
|
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
|
|
const handleAcceptJustification = (notif: Notification) => {
|
|
// Procura registros de falta pendentes de aceitação
|
|
const pendingAbsences = (data.attendance || []).filter(a =>
|
|
a.type === 'absence' && a.justification && !a.justificationAccepted
|
|
);
|
|
|
|
if (pendingAbsences.length > 0) {
|
|
// Tenta achar pelo studentId mencionado ou associado à notificação
|
|
const targetId = (notif as any).fromStudentId;
|
|
const matchedAbsence = targetId
|
|
? pendingAbsences.find(a => a.studentId === targetId) || pendingAbsences[0]
|
|
: pendingAbsences[0];
|
|
|
|
const updatedAttendance = (data.attendance || []).map(a =>
|
|
a.id === matchedAbsence.id ? { ...a, justificationAccepted: true } : a
|
|
);
|
|
|
|
updateData({ attendance: updatedAttendance });
|
|
dbService.saveData({ ...data, attendance: updatedAttendance });
|
|
handleMarkAsRead(notif.id);
|
|
} else {
|
|
// Se não encontrou pendentes, apenas marca como lida
|
|
handleMarkAsRead(notif.id);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="fixed top-4 right-16 md:top-6 md:right-8 z-50">
|
|
<button
|
|
onClick={() => setIsOpen(!isOpen)}
|
|
className={`relative p-2.5 rounded-full shadow-lg border transition-all ${
|
|
unreadCount > 0
|
|
? 'bg-amber-50 text-amber-600 border-amber-200 hover:bg-amber-100 hover:shadow-xl shadow-amber-100'
|
|
: 'bg-white text-slate-600 border-slate-100 hover:text-indigo-600 hover:shadow-xl'
|
|
}`}
|
|
title="Notificações do Sistema"
|
|
>
|
|
<Bell size={22} className={unreadCount > 0 ? "animate-bounce" : ""} />
|
|
{unreadCount > 0 && (
|
|
<span className="absolute -top-1.5 -right-1.5 bg-red-500 text-white text-[10px] font-black w-5 h-5 flex items-center justify-center rounded-full border-2 border-white shadow-sm animate-pulse">
|
|
{unreadCount > 99 ? '99+' : unreadCount}
|
|
</span>
|
|
)}
|
|
</button>
|
|
|
|
{isOpen && (
|
|
<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">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>
|
|
<div className="flex items-center gap-2">
|
|
<button onClick={handleClearRead} className="p-1.5 text-slate-400 hover:bg-slate-200 hover:text-red-500 rounded-lg transition-colors" title="Limpar Lidas">
|
|
<Trash2 size={16} />
|
|
</button>
|
|
<button onClick={() => setIsOpen(false)} className="p-1.5 text-slate-400 hover:bg-slate-200 hover:text-slate-700 rounded-lg transition-colors">
|
|
<X size={16} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="overflow-y-auto p-2 flex-1 relative">
|
|
{adminNotifs.length === 0 ? (
|
|
<div className="py-12 text-center text-slate-400">
|
|
<Bell size={32} className="mx-auto mb-2 opacity-20" />
|
|
<p className="text-sm font-bold">Nenhuma notificação</p>
|
|
<p className="text-xs mt-1">Sua caixa de entrada está limpa.</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{adminNotifs.map(notif => {
|
|
const isJustificativa = notif.title.toLowerCase().includes('justificativa') || notif.message.toLowerCase().includes('justificativa');
|
|
|
|
let displayMessage = notif.message;
|
|
let justificationMotive = '';
|
|
let attachmentFromMessage = null;
|
|
let metadata: any = {};
|
|
|
|
if (notif.message.startsWith('{')) {
|
|
try {
|
|
const parsed = JSON.parse(notif.message);
|
|
displayMessage = parsed.text || parsed.motivo || displayMessage;
|
|
justificationMotive = parsed.motivo || '';
|
|
attachmentFromMessage = parsed.arquivo || parsed.arquivo_base64 || null;
|
|
} 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 ${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">
|
|
<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>
|
|
</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-xs font-medium leading-relaxed mb-2 ${notif.read ? 'text-slate-400' : 'text-slate-600'}`}>
|
|
{displayMessage}
|
|
</p>
|
|
{isJustificativa && justificationMotive && (
|
|
<div className="bg-amber-50 p-2 rounded-lg border border-amber-100 mb-3">
|
|
<p className="text-[11px] font-bold text-amber-800 italic uppercase mb-1">Motivo enviado:</p>
|
|
<p className="text-xs text-amber-700 font-medium">"{justificationMotive}"</p>
|
|
</div>
|
|
)}
|
|
{(!notif.read) && (
|
|
<div className="flex justify-end mt-2 gap-2 transition-opacity">
|
|
{isJustificativa && (
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
const targetId = (notif as any).fromStudentId || notif.studentId;
|
|
if (onNavigateToStudent && targetId !== 'admin') {
|
|
onNavigateToStudent(targetId);
|
|
} else {
|
|
setView(View.AttendanceQuery);
|
|
}
|
|
}}
|
|
className="text-[10px] font-black uppercase text-amber-600 bg-amber-50 hover:bg-amber-100 px-2 py-1 rounded-lg flex items-center gap-1 transition-colors"
|
|
>
|
|
<ShieldCheck size={12} /> Ver Histórico
|
|
</button>
|
|
)}
|
|
{isJustificativa && (
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); handleAcceptJustification(notif); }}
|
|
className="text-[10px] font-black uppercase text-emerald-600 bg-emerald-50 hover:emerald-100 px-2 py-1 rounded-lg flex items-center gap-1 transition-colors"
|
|
>
|
|
<CheckCircle size={12} /> Aceitar
|
|
</button>
|
|
)}
|
|
{finalAttachment && (
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setViewingAttachment(finalAttachment);
|
|
setNotifWithAttachment(notif);
|
|
}}
|
|
className="text-[10px] font-black uppercase text-indigo-600 bg-indigo-50 hover:bg-indigo-100 px-2 py-1 rounded-lg flex items-center gap-1 transition-colors"
|
|
>
|
|
<Paperclip size={12} /> Ver Anexo
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); handleMarkAsRead(notif.id); }}
|
|
className="text-[10px] font-black uppercase text-indigo-600 bg-indigo-50 hover:bg-indigo-100 px-2 py-1 rounded-lg flex items-center gap-1 transition-colors"
|
|
>
|
|
<CheckCircle size={12} /> Lida
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{viewingAttachment && (
|
|
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
|
|
<div className="bg-white rounded-2xl w-full max-w-4xl max-h-[90vh] flex flex-col overflow-hidden shadow-2xl animate-in zoom-in-95 duration-200">
|
|
<div className="p-4 border-b flex items-center justify-between bg-slate-50">
|
|
<h3 className="font-black text-slate-800 flex items-center gap-2">
|
|
<FileText size={20} className="text-indigo-600" /> Visualização do Documento
|
|
</h3>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={handleDeleteAttachment}
|
|
className="px-3 py-1.5 bg-red-50 text-red-600 rounded-lg text-xs font-bold hover:bg-red-100 flex items-center gap-1.5 transition-colors"
|
|
>
|
|
<Trash2 size={14} /> Excluir Arquivo
|
|
</button>
|
|
<button
|
|
onClick={() => { setViewingAttachment(null); setNotifWithAttachment(null); }}
|
|
className="p-2 text-slate-400 hover:bg-slate-200 hover:text-slate-700 rounded-lg transition-colors"
|
|
>
|
|
<X size={20} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="flex-1 overflow-auto bg-slate-200 p-4 flex items-center justify-center">
|
|
{viewingAttachment.startsWith('data:application/pdf') || viewingAttachment.includes('.pdf') ? (
|
|
<iframe src={viewingAttachment} className="w-full h-full min-h-[70vh] rounded-lg shadow-sm bg-white" />
|
|
) : (
|
|
<img src={viewingAttachment} className="max-w-full max-h-full object-contain rounded-lg shadow-sm" alt="Documento" />
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default AdminNotifications;
|