6e81718725
- remove z-[1002] do wrapper sempre-presente do sino (furava modais z-50..z-120) - z aplicado só ao dropdown aberto (z-50) + backdrop (z-40) - doc: RX apontado ao banco dev isolado Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
168 lines
10 KiB
TypeScript
168 lines
10 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { Bell, X, Calendar, DollarSign, AlertCircle, UserCheck, MessageSquare, ShieldAlert } from 'lucide-react';
|
|
import { HybridBackend } from '../services/backend.ts';
|
|
import { Realtime } from '../services/realtime.ts';
|
|
import { Notificacao } from '../types.ts';
|
|
|
|
export const NotificationCenter: React.FC = () => {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [notificacoes, setNotificacoes] = useState<Notificacao[]>([]);
|
|
const [unreadCount, setUnreadCount] = useState(0);
|
|
|
|
const fetch = async () => {
|
|
try {
|
|
const data = await HybridBackend.getNotifications();
|
|
setNotificacoes(data);
|
|
setUnreadCount(data.filter(n => !n.lida).length);
|
|
} catch (err) {
|
|
console.error("Erro ao carregar notificações");
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetch();
|
|
// Tempo real: novas notificações aparecem na hora; polling fica como fallback (60s).
|
|
const unsub = Realtime.subscribe((msg) => { if (msg?.type === 'notificacao') fetch(); });
|
|
const interval = setInterval(fetch, 60000);
|
|
return () => { unsub(); clearInterval(interval); };
|
|
}, []);
|
|
|
|
const toggleOpen = () => setIsOpen(!isOpen);
|
|
|
|
const markAsRead = async (id: string) => {
|
|
await HybridBackend.markNotificationRead(id);
|
|
setNotificacoes(prev => prev.map(n => n.id === id ? { ...n, lida: true } : n));
|
|
setUnreadCount(prev => Math.max(0, prev - 1));
|
|
};
|
|
|
|
const getIcon = (tipo: string) => {
|
|
switch (tipo) {
|
|
case 'agenda': return <Calendar size={18} className="text-blue-500" />;
|
|
case 'financeiro': return <DollarSign size={18} className="text-emerald-500" />;
|
|
case 'lead': return <UserCheck size={18} className="text-amber-500" />;
|
|
case 'sistema': return <ShieldAlert size={18} className="text-red-500" />;
|
|
default: return <MessageSquare size={18} className="text-gray-500" />;
|
|
}
|
|
};
|
|
|
|
const getTypeLabel = (tipo: string) => {
|
|
switch (tipo) {
|
|
case 'agenda': return 'Agenda';
|
|
case 'financeiro': return 'Financeiro';
|
|
case 'lead': return 'Novo Lead';
|
|
case 'sistema': return 'Sistema';
|
|
default: return 'Geral';
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="relative">
|
|
<div className="relative">
|
|
<button
|
|
onClick={toggleOpen}
|
|
className="group relative w-11 h-11 flex items-center justify-center rounded-xl bg-white/80 backdrop-blur-md border border-white/20 shadow-[0_8px_32px_rgba(0,0,0,0.06)] hover:shadow-[0_8px_32px_rgba(59,130,246,0.15)] hover:border-teal-400/50 transition-all duration-300"
|
|
>
|
|
<Bell size={22} className="text-slate-600 group-hover:text-teal-600 transition-colors" />
|
|
{unreadCount > 0 && (
|
|
<span className="absolute -top-1.5 -right-1.5 flex h-5 w-5">
|
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
|
|
<span className="relative inline-flex rounded-full h-5 w-5 bg-red-500 text-[10px] font-bold text-white items-center justify-center shadow-lg">
|
|
{unreadCount}
|
|
</span>
|
|
</span>
|
|
)}
|
|
</button>
|
|
|
|
{isOpen && (
|
|
<>
|
|
<div className="fixed inset-0 z-40" onClick={toggleOpen} />
|
|
<div className="absolute right-0 mt-4 w-[380px] z-50 bg-white/95 backdrop-blur-lg rounded-2xl shadow-[0_20px_50px_rgba(0,0,0,0.15)] border border-white/20 overflow-hidden animate-in fade-in zoom-in-95 slide-in-from-top-4 duration-300 transform origin-top-right">
|
|
<div className="px-6 py-5 bg-gradient-to-r from-slate-50 to-white border-b border-slate-100 flex justify-between items-center">
|
|
<div>
|
|
<h3 className="font-bold text-slate-800 text-lg tracking-tight">Centro de Notificações</h3>
|
|
<p className="text-[11px] text-slate-400 font-medium uppercase tracking-wider">Você tem {unreadCount} novas mensagens</p>
|
|
</div>
|
|
<button
|
|
onClick={toggleOpen}
|
|
className="p-2 hover:bg-slate-100 rounded-full transition-colors"
|
|
>
|
|
<X size={18} className="text-slate-400" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="max-h-[450px] overflow-y-auto custom-scrollbar bg-slate-50/30">
|
|
{notificacoes.length === 0 ? (
|
|
<div className="py-20 flex flex-col items-center justify-center text-center px-8">
|
|
<div className="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mb-4">
|
|
<Bell size={32} className="text-slate-300" />
|
|
</div>
|
|
<p className="text-slate-500 font-medium">Tudo limpo por aqui!</p>
|
|
<p className="text-xs text-slate-400 mt-1">Não há notificações para mostrar no momento.</p>
|
|
</div>
|
|
) : (
|
|
<div className="divide-y divide-slate-100">
|
|
{notificacoes.map(notif => (
|
|
<div
|
|
key={notif.id}
|
|
className={`group p-5 hover:bg-white transition-all duration-200 cursor-default ${!notif.lida ? 'bg-teal-50/40 relative' : ''}`}
|
|
>
|
|
{!notif.lida && (
|
|
<div className="absolute left-0 top-0 bottom-0 w-1 bg-teal-500 rounded-r-full" />
|
|
)}
|
|
<div className="flex gap-4">
|
|
<div className={`mt-1 h-10 w-10 flex-shrink-0 flex items-center justify-center rounded-xl ${notif.tipo === 'agenda' ? 'bg-blue-100 text-blue-600' :
|
|
notif.tipo === 'financeiro' ? 'bg-emerald-100 text-emerald-600' :
|
|
notif.tipo === 'lead' ? 'bg-amber-100 text-amber-600' :
|
|
'bg-red-100 text-red-600'
|
|
}`}>
|
|
{getIcon(notif.tipo)}
|
|
</div>
|
|
<div className="flex-1">
|
|
<div className="flex justify-between items-center mb-1">
|
|
<span className="text-[11px] font-bold text-slate-400 uppercase tracking-wide">
|
|
{getTypeLabel(notif.tipo)}
|
|
</span>
|
|
<span className="text-[10px] text-slate-400">
|
|
{new Date(notif.data).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}
|
|
</span>
|
|
</div>
|
|
<h4 className={`text-sm font-bold leading-tight ${!notif.lida ? 'text-slate-900' : 'text-slate-600'}`}>
|
|
{notif.titulo}
|
|
</h4>
|
|
<p className="text-xs text-slate-500 mt-1 line-clamp-2 leading-relaxed">
|
|
{notif.mensagem}
|
|
</p>
|
|
{!notif.lida && (
|
|
<button
|
|
onClick={() => markAsRead(notif.id)}
|
|
className="text-[11px] text-teal-600 font-bold mt-3 opacity-0 group-hover:opacity-100 transition-opacity hover:underline"
|
|
>
|
|
MARCAR COMO LIDA
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="p-4 bg-white border-t border-slate-100 text-center">
|
|
<button
|
|
onClick={() => {
|
|
history.pushState({}, '', '/notificacoes');
|
|
setIsOpen(false);
|
|
}}
|
|
className="text-xs font-bold text-slate-500 hover:text-teal-600 transition-colors tracking-widest uppercase"
|
|
>
|
|
Histórico Completo
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}; |