// Histórico de Notificações — a sineta só mostra as 30 últimas e some. // Aqui dá pra procurar depois: "quando foi que o fulano abriu aquele // orçamento?". Busca por texto, filtro por tipo e por lidas/não lidas. const NOTIF_TIPOS = { budget_viewed: { label: 'Visualização de orçamento', cor: '#8B5CF6', icon: 'eye' }, budget_approved: { label: 'Aprovação do cliente', cor: '#10B981', icon: 'check' }, budget_ajuste: { label: 'Ajuste solicitado', cor: '#F59E0B', icon: 'alert' }, approval_pending: { label: 'Aprovação aguardando', cor: '#F59E0B', icon: 'clock' }, operation_update: { label: 'Atualização da operação', cor: '#3B82F6', icon: 'zap' }, system: { label: 'Sistema', cor: '#64748B', icon: 'bell' }, }; const NOTIF_POR_PAGINA = 25; function Notificacoes({ onNavigate }) { const [notifs, setNotifs] = React.useState([]); const [total, setTotal] = React.useState(0); const [loading, setLoading] = React.useState(true); const [pagina, setPagina] = React.useState(0); const [busca, setBusca] = React.useState(''); const [buscaAtiva, setBuscaAtiva] = React.useState(''); const [tipo, setTipo] = React.useState('todos'); const [lidas, setLidas] = React.useState('todas'); const [toast, setToast] = React.useState(null); const showToast = (msg, type = 'success') => { setToast({ msg, type }); setTimeout(() => setToast(null), 3000); }; // Debounce da busca pra não bater no banco a cada tecla React.useEffect(() => { const t = setTimeout(() => { setBuscaAtiva(busca.trim()); setPagina(0); }, 300); return () => clearTimeout(t); }, [busca]); const carregar = React.useCallback(async () => { setLoading(true); let q = sb.from('notifications').select('*', { count: 'exact' }); if (tipo !== 'todos') q = q.eq('type', tipo); if (lidas === 'nao') q = q.eq('read', false); if (lidas === 'sim') q = q.eq('read', true); if (buscaAtiva) { const esc = buscaAtiva.replace(/[%_,]/g, ''); q = q.or(`title.ilike.%${esc}%,message.ilike.%${esc}%`); } const de = pagina * NOTIF_POR_PAGINA; const { data, count } = await q.order('created_at', { ascending: false }).range(de, de + NOTIF_POR_PAGINA - 1); setNotifs(data || []); setTotal(count || 0); setLoading(false); }, [tipo, lidas, buscaAtiva, pagina]); React.useEffect(() => { carregar(); }, [carregar]); const naoLidas = notifs.filter(n => !n.read); const marcarUma = async (n) => { if (n.read) return; await markNotificationRead(n.id); setNotifs(prev => prev.map(x => x.id === n.id ? { ...x, read: true, read_at: new Date().toISOString() } : x)); }; const marcarTodasDaPagina = async () => { if (!naoLidas.length) return; await markAllNotificationsRead(naoLidas.map(n => n.id)); showToast(`${naoLidas.length} marcada${naoLidas.length !== 1 ? 's' : ''} como lida${naoLidas.length !== 1 ? 's' : ''}.`); carregar(); }; const abrir = async (n) => { await marcarUma(n); if (n.link && onNavigate) onNavigate(n.link); }; const totalPaginas = Math.max(1, Math.ceil(total / NOTIF_POR_PAGINA)); const temFiltro = tipo !== 'todos' || lidas !== 'todas' || !!buscaAtiva; const limparFiltros = () => { setTipo('todos'); setLidas('todas'); setBusca(''); setPagina(0); }; const fmtData = (iso) => { const d = new Date(iso); const hoje = new Date(); hoje.setHours(0, 0, 0, 0); const dia = new Date(d); dia.setHours(0, 0, 0, 0); const difDias = Math.round((hoje - dia) / 86400000); const hora = d.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }); if (difDias === 0) return `Hoje, ${hora}`; if (difDias === 1) return `Ontem, ${hora}`; return `${d.toLocaleDateString('pt-BR', { day: '2-digit', month: 'short', year: 'numeric' })} · ${hora}`; }; return (

Histórico de Notificações

Tudo que já passou pela sineta — inclusive o que você não viu na hora.

{naoLidas.length > 0 && ( Marcar as {naoLidas.length} desta página )}
{/* Filtros */}
setBusca(e.target.value)} placeholder="Nome do cliente, código do orçamento, texto..." style={{ width: '100%', boxSizing: 'border-box', border: `1px solid ${DS.colors.border}`, borderRadius: DS.radius.sm, padding: '9px 12px 9px 34px', fontSize: 13, outline: 'none', background: '#fff', fontFamily: 'inherit', color: DS.colors.text }} />
{ setLidas(v); setPagina(0); }} options={[ { value: 'todas', label: 'Lidas e não lidas' }, { value: 'nao', label: 'Só não lidas' }, { value: 'sim', label: 'Só lidas' }, ]} />
{temFiltro && ( )}
{loading ? (
) : notifs.length === 0 ? (
{temFiltro ? 'Nada encontrado com esses filtros.' : 'Nenhuma notificação ainda.'}
{temFiltro && (
Tente limpar os filtros ou buscar outro termo.
)}
) : ( <>
{total} notificaç{total === 1 ? 'ão' : 'ões'} · página {pagina + 1} de {totalPaginas}
{notifs.map((n, i) => { const meta = NOTIF_TIPOS[n.type] || NOTIF_TIPOS.system; return (
abrir(n)} style={{ display: 'flex', gap: 14, padding: '14px 20px', alignItems: 'flex-start', borderBottom: i < notifs.length - 1 ? `1px solid ${DS.colors.border}` : 'none', cursor: 'pointer', background: n.read ? '#fff' : '#FFF7ED', transition: 'background 0.15s', }} onMouseEnter={e => e.currentTarget.style.background = '#F8FAFC'} onMouseLeave={e => e.currentTarget.style.background = n.read ? '#fff' : '#FFF7ED'}>
{!n.read &&
}
{n.title}
{n.message &&
{n.message}
}
{meta.label} {fmtData(n.created_at)} {n.read && n.read_at && ( · lida em {new Date(n.read_at).toLocaleDateString('pt-BR')} )}
{n.link && (
)}
); })} {totalPaginas > 1 && (
setPagina(p => Math.max(0, p - 1))}>Anterior {pagina + 1} / {totalPaginas} = totalPaginas - 1} onClick={() => setPagina(p => p + 1)}>Próxima
)} )} {toast && setToast(null)} />}
); } Object.assign(window, { Notificacoes, NOTIF_TIPOS });