Files
sgse-app/apps/web/src/lib/components/chat/NotificationBell.svelte

593 lines
19 KiB
Svelte

<script lang="ts">
import { useQuery, useConvexClient } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import { notificacoesCount } from '$lib/stores/chatStore';
import { formatDistanceToNow } from 'date-fns';
import { ptBR } from 'date-fns/locale';
import { Bell, Mail, AtSign, Users, Calendar, Clock, BellOff, Trash2, X } from 'lucide-svelte';
// Queries e Client
const client = useConvexClient();
// Query para contar apenas não lidas (para o badge)
const countQuery = useQuery(api.chat.contarNotificacoesNaoLidas, {});
// Query para obter TODAS as notificações (para o popup)
const todasNotificacoesQuery = useQuery(api.chat.obterNotificacoes, {
apenasPendentes: false
});
// Usuário atual
const currentUser = useQuery(api.auth.getCurrentUser, {});
let modalOpen = $state(false);
let notificacoesFerias = $state<
Array<{
_id: string;
mensagem: string;
tipo: string;
_creationTime: number;
}>
>([]);
let notificacoesAusencias = $state<
Array<{
_id: string;
mensagem: string;
tipo: string;
_creationTime: number;
}>
>([]);
let limpandoNotificacoes = $state(false);
// Helpers para obter valores das queries
let count = $derived((typeof countQuery === 'number' ? countQuery : countQuery?.data) ?? 0);
let todasNotificacoes = $derived(
(Array.isArray(todasNotificacoesQuery)
? todasNotificacoesQuery
: todasNotificacoesQuery?.data) ?? []
);
// Separar notificações lidas e não lidas
let notificacoesNaoLidas = $derived(todasNotificacoes.filter((n) => !n.lida));
let notificacoesLidas = $derived(todasNotificacoes.filter((n) => n.lida));
// Atualizar contador no store
$effect(() => {
const totalNotificacoes =
count + (notificacoesFerias?.length || 0) + (notificacoesAusencias?.length || 0);
notificacoesCount.set(totalNotificacoes);
});
// Buscar notificações de férias
async function buscarNotificacoesFerias() {
try {
const usuarioId = currentUser?.data?._id;
if (usuarioId) {
const notifsFerias = await client.query(api.ferias.obterNotificacoesNaoLidas, {
usuarioId
});
notificacoesFerias = notifsFerias || [];
}
} catch (e) {
console.error('Erro ao buscar notificações de férias:', e);
}
}
// Buscar notificações de ausências
async function buscarNotificacoesAusencias() {
try {
const usuarioId = currentUser?.data?._id;
if (usuarioId) {
try {
const notifsAusencias = await client.query(api.ausencias.obterNotificacoesNaoLidas, {
usuarioId
});
notificacoesAusencias = notifsAusencias || [];
} catch (queryError: unknown) {
// Silenciar erros de timeout e função não encontrada
const errorMessage =
queryError instanceof Error ? queryError.message : String(queryError);
const isTimeout = errorMessage.includes('timed out') || errorMessage.includes('timeout');
const isFunctionNotFound = errorMessage.includes('Could not find public function');
if (!isTimeout && !isFunctionNotFound) {
console.error('Erro ao buscar notificações de ausências:', queryError);
}
notificacoesAusencias = [];
}
}
} catch (e) {
// Erro geral - silenciar se for sobre função não encontrada ou timeout
const errorMessage = e instanceof Error ? e.message : String(e);
const isTimeout = errorMessage.includes('timed out') || errorMessage.includes('timeout');
const isFunctionNotFound = errorMessage.includes('Could not find public function');
if (!isTimeout && !isFunctionNotFound) {
console.error('Erro ao buscar notificações de ausências:', e);
}
}
}
// Atualizar notificações periodicamente
$effect(() => {
buscarNotificacoesFerias();
buscarNotificacoesAusencias();
const interval = setInterval(() => {
buscarNotificacoesFerias();
buscarNotificacoesAusencias();
}, 30000); // A cada 30s
return () => clearInterval(interval);
});
function formatarTempo(timestamp: number): string {
try {
return formatDistanceToNow(new Date(timestamp), {
addSuffix: true,
locale: ptBR
});
} catch {
return 'agora';
}
}
async function handleMarcarTodasLidas() {
await client.mutation(api.chat.marcarTodasNotificacoesLidas, {});
// Marcar todas as notificações de férias como lidas
for (const notif of notificacoesFerias) {
await client.mutation(api.ferias.marcarComoLida, {
notificacaoId: notif._id
});
}
// Marcar todas as notificações de ausências como lidas
for (const notif of notificacoesAusencias) {
await client.mutation(api.ausencias.marcarComoLida, {
notificacaoId: notif._id
});
}
await buscarNotificacoesFerias();
await buscarNotificacoesAusencias();
}
async function handleLimparTodasNotificacoes() {
limpandoNotificacoes = true;
try {
await client.mutation(api.chat.limparTodasNotificacoes, {});
await buscarNotificacoesFerias();
await buscarNotificacoesAusencias();
} catch (error) {
console.error('Erro ao limpar notificações:', error);
} finally {
limpandoNotificacoes = false;
}
}
async function handleLimparNotificacoesNaoLidas() {
limpandoNotificacoes = true;
try {
await client.mutation(api.chat.limparNotificacoesNaoLidas, {});
await buscarNotificacoesFerias();
await buscarNotificacoesAusencias();
} catch (error) {
console.error('Erro ao limpar notificações não lidas:', error);
} finally {
limpandoNotificacoes = false;
}
}
async function handleClickNotificacao(notificacaoId: string) {
await client.mutation(api.chat.marcarNotificacaoLida, {
notificacaoId: notificacaoId as any
});
}
async function handleClickNotificacaoFerias(notificacaoId: string) {
await client.mutation(api.ferias.marcarComoLida, {
notificacaoId: notificacaoId
});
await buscarNotificacoesFerias();
// Redirecionar para a página de férias
window.location.href = '/recursos-humanos/ferias';
}
async function handleClickNotificacaoAusencias(notificacaoId: string) {
await client.mutation(api.ausencias.marcarComoLida, {
notificacaoId: notificacaoId
});
await buscarNotificacoesAusencias();
// Redirecionar para a página de perfil na aba de ausências
window.location.href = '/perfil?aba=minhas-ausencias';
}
function openModal() {
modalOpen = true;
}
function closeModal() {
modalOpen = false;
}
// Fechar popup ao clicar fora ou pressionar Escape
$effect(() => {
if (!modalOpen) return;
function handleClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement;
if (!target.closest('.notification-popup') && !target.closest('.notification-bell')) {
modalOpen = false;
}
}
function handleEscape(event: KeyboardEvent) {
if (event.key === 'Escape') {
modalOpen = false;
}
}
document.addEventListener('click', handleClickOutside);
document.addEventListener('keydown', handleEscape);
return () => {
document.removeEventListener('click', handleClickOutside);
document.removeEventListener('keydown', handleEscape);
};
});
</script>
<div class="notification-bell relative">
<!-- Botão de Notificação ULTRA MODERNO (igual ao perfil) -->
<button
type="button"
tabindex="0"
class="group relative flex h-14 w-14 items-center justify-center overflow-hidden rounded-2xl transition-all duration-300 hover:scale-105"
style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); box-shadow: 0 8px 24px -4px rgba(102, 126, 234, 0.4);"
onclick={openModal}
aria-label="Notificações"
>
<!-- Efeito de brilho no hover -->
<div
class="absolute inset-0 bg-linear-to-br from-white/0 to-white/20 opacity-0 transition-opacity duration-300 group-hover:opacity-100"
></div>
<!-- Anel de pulso sutil -->
<div
class="absolute inset-0 rounded-2xl"
style="animation: pulse-ring-subtle 3s cubic-bezier(0.4, 0, 0.6, 1) infinite;"
></div>
<!-- Glow effect quando tem notificações -->
{#if count && count > 0}
<div class="bg-error/30 absolute inset-0 animate-pulse rounded-2xl blur-lg"></div>
{/if}
<!-- Ícone do sino PREENCHIDO moderno -->
<Bell
class="relative z-10 h-7 w-7 text-white transition-all duration-300 group-hover:scale-110"
style="filter: drop-shadow(0 2px 8px rgba(0,0,0,0.3)); animation: {count && count > 0
? 'bell-ring 2s ease-in-out infinite'
: 'none'};"
fill="currentColor"
/>
<!-- Badge premium MODERNO com gradiente -->
{#if count + (notificacoesFerias?.length || 0) > 0}
{@const totalCount = count + (notificacoesFerias?.length || 0)}
<span
class="absolute -top-1 -right-1 z-20 flex h-6 w-6 items-center justify-center rounded-full text-[10px] font-black text-white shadow-xl ring-2 ring-white"
style="background: linear-gradient(135deg, #ff416c, #ff4b2b); box-shadow: 0 8px 24px -4px rgba(255, 65, 108, 0.6), 0 4px 12px -2px rgba(255, 75, 43, 0.4); animation: badge-bounce 2s ease-in-out infinite;"
>
{totalCount > 9 ? '9+' : totalCount}
</span>
{/if}
</button>
<!-- Popup Flutuante de Notificações -->
{#if modalOpen}
<div
class="notification-popup bg-base-100 border-base-300 fixed top-24 right-4 z-[100] flex max-h-[calc(100vh-7rem)] w-[calc(100vw-2rem)] max-w-2xl flex-col overflow-hidden rounded-2xl border shadow-2xl backdrop-blur-sm"
style="animation: slideDown 0.2s ease-out;"
>
<!-- Header -->
<div
class="border-base-300 from-primary/5 to-primary/10 flex items-center justify-between border-b bg-linear-to-r px-6 py-4"
>
<h3 class="text-primary text-2xl font-bold">Notificações</h3>
<div class="flex items-center gap-2">
{#if notificacoesNaoLidas.length > 0}
<button
type="button"
class="btn btn-sm btn-ghost"
onclick={handleLimparNotificacoesNaoLidas}
disabled={limpandoNotificacoes}
>
<Trash2 class="h-4 w-4" />
Limpar não lidas
</button>
{/if}
{#if todasNotificacoes.length > 0}
<button
type="button"
class="btn btn-sm btn-error btn-outline"
onclick={handleLimparTodasNotificacoes}
disabled={limpandoNotificacoes}
>
<Trash2 class="h-4 w-4" />
Limpar todas
</button>
{/if}
<button type="button" class="btn btn-sm btn-circle btn-ghost" onclick={closeModal}>
<X class="h-5 w-5" />
</button>
</div>
</div>
<!-- Lista de notificações -->
<div class="flex-1 overflow-y-auto px-2 py-4">
{#if todasNotificacoes.length > 0 || notificacoesFerias.length > 0 || notificacoesAusencias.length > 0}
<!-- Notificações não lidas -->
{#if notificacoesNaoLidas.length > 0}
<div class="mb-4">
<h4 class="text-primary mb-2 px-2 text-sm font-semibold">Não lidas</h4>
{#each notificacoesNaoLidas as notificacao (notificacao._id)}
<button
type="button"
class="hover:bg-base-200 border-primary mb-2 w-full rounded-lg border-l-4 px-4 py-3 text-left transition-colors"
onclick={() => handleClickNotificacao(notificacao._id)}
>
<div class="flex items-start gap-3">
<!-- Ícone -->
<div class="mt-1 shrink-0">
{#if notificacao.tipo === 'nova_mensagem'}
<Mail class="text-primary h-5 w-5" strokeWidth={1.5} />
{:else if notificacao.tipo === 'mencao'}
<AtSign class="text-warning h-5 w-5" strokeWidth={1.5} />
{:else}
<Users class="text-info h-5 w-5" strokeWidth={1.5} />
{/if}
</div>
<!-- Conteúdo -->
<div class="min-w-0 flex-1">
{#if notificacao.tipo === 'nova_mensagem' && notificacao.remetente}
<p class="text-primary text-sm font-semibold">
{notificacao.remetente.nome}
</p>
<p class="text-base-content/70 mt-1 line-clamp-2 text-xs">
{notificacao.descricao}
</p>
{:else if notificacao.tipo === 'mencao' && notificacao.remetente}
<p class="text-warning text-sm font-semibold">
{notificacao.remetente.nome} mencionou você
</p>
<p class="text-base-content/70 mt-1 line-clamp-2 text-xs">
{notificacao.descricao}
</p>
{:else}
<p class="text-base-content text-sm font-semibold">
{notificacao.titulo}
</p>
<p class="text-base-content/70 mt-1 line-clamp-2 text-xs">
{notificacao.descricao}
</p>
{/if}
<p class="text-base-content/50 mt-1 text-xs">
{formatarTempo(notificacao.criadaEm)}
</p>
</div>
<!-- Indicador de não lida -->
<div class="shrink-0">
<div class="bg-primary h-2 w-2 rounded-full"></div>
</div>
</div>
</button>
{/each}
</div>
{/if}
<!-- Notificações lidas -->
{#if notificacoesLidas.length > 0}
<div class="mb-4">
<h4 class="text-base-content/60 mb-2 px-2 text-sm font-semibold">Lidas</h4>
{#each notificacoesLidas as notificacao (notificacao._id)}
<button
type="button"
class="hover:bg-base-200 mb-2 w-full rounded-lg px-4 py-3 text-left opacity-75 transition-colors"
onclick={() => handleClickNotificacao(notificacao._id)}
>
<div class="flex items-start gap-3">
<!-- Ícone -->
<div class="mt-1 shrink-0">
{#if notificacao.tipo === 'nova_mensagem'}
<Mail class="text-primary/60 h-5 w-5" strokeWidth={1.5} />
{:else if notificacao.tipo === 'mencao'}
<AtSign class="text-warning/60 h-5 w-5" strokeWidth={1.5} />
{:else}
<Users class="text-info/60 h-5 w-5" strokeWidth={1.5} />
{/if}
</div>
<!-- Conteúdo -->
<div class="min-w-0 flex-1">
{#if notificacao.tipo === 'nova_mensagem' && notificacao.remetente}
<p class="text-primary/70 text-sm font-medium">
{notificacao.remetente.nome}
</p>
<p class="text-base-content/60 mt-1 line-clamp-2 text-xs">
{notificacao.descricao}
</p>
{:else if notificacao.tipo === 'mencao' && notificacao.remetente}
<p class="text-warning/70 text-sm font-medium">
{notificacao.remetente.nome} mencionou você
</p>
<p class="text-base-content/60 mt-1 line-clamp-2 text-xs">
{notificacao.descricao}
</p>
{:else}
<p class="text-base-content/70 text-sm font-medium">
{notificacao.titulo}
</p>
<p class="text-base-content/60 mt-1 line-clamp-2 text-xs">
{notificacao.descricao}
</p>
{/if}
<p class="text-base-content/50 mt-1 text-xs">
{formatarTempo(notificacao.criadaEm)}
</p>
</div>
</div>
</button>
{/each}
</div>
{/if}
<!-- Notificações de Férias -->
{#if notificacoesFerias.length > 0}
<div class="mb-4">
<h4 class="mb-2 px-2 text-sm font-semibold text-purple-600">Férias</h4>
{#each notificacoesFerias as notificacao (notificacao._id)}
<button
type="button"
class="hover:bg-base-200 mb-2 w-full rounded-lg border-l-4 border-purple-600 px-4 py-3 text-left transition-colors"
onclick={() => handleClickNotificacaoFerias(notificacao._id)}
>
<div class="flex items-start gap-3">
<!-- Ícone -->
<div class="mt-1 shrink-0">
<Calendar class="h-5 w-5 text-purple-600" strokeWidth={2} />
</div>
<!-- Conteúdo -->
<div class="min-w-0 flex-1">
<p class="text-base-content text-sm font-medium">
{notificacao.mensagem}
</p>
<p class="text-base-content/50 mt-1 text-xs">
{formatarTempo(notificacao._creationTime)}
</p>
</div>
<!-- Badge -->
<div class="shrink-0">
<div class="badge badge-primary badge-xs"></div>
</div>
</div>
</button>
{/each}
</div>
{/if}
<!-- Notificações de Ausências -->
{#if notificacoesAusencias.length > 0}
<div class="mb-4">
<h4 class="mb-2 px-2 text-sm font-semibold text-orange-600">Ausências</h4>
{#each notificacoesAusencias as notificacao (notificacao._id)}
<button
type="button"
class="hover:bg-base-200 mb-2 w-full rounded-lg border-l-4 border-orange-600 px-4 py-3 text-left transition-colors"
onclick={() => handleClickNotificacaoAusencias(notificacao._id)}
>
<div class="flex items-start gap-3">
<!-- Ícone -->
<div class="mt-1 shrink-0">
<Clock class="h-5 w-5 text-orange-600" strokeWidth={2} />
</div>
<!-- Conteúdo -->
<div class="min-w-0 flex-1">
<p class="text-base-content text-sm font-medium">
{notificacao.mensagem}
</p>
<p class="text-base-content/50 mt-1 text-xs">
{formatarTempo(notificacao._creationTime)}
</p>
</div>
<!-- Badge -->
<div class="shrink-0">
<div class="badge badge-warning badge-xs"></div>
</div>
</div>
</button>
{/each}
</div>
{/if}
{:else}
<!-- Sem notificações -->
<div class="text-base-content/50 px-4 py-12 text-center">
<BellOff class="mx-auto mb-4 h-16 w-16 opacity-50" strokeWidth={1.5} />
<p class="text-base font-medium">Nenhuma notificação</p>
<p class="mt-1 text-sm">Você está em dia!</p>
</div>
{/if}
</div>
<!-- Footer com estatísticas -->
{#if todasNotificacoes.length > 0 || notificacoesFerias.length > 0 || notificacoesAusencias.length > 0}
<div class="border-base-300 bg-base-200/50 border-t px-6 py-4">
<div class="text-base-content/60 flex items-center justify-between text-xs">
<span>
Total: {todasNotificacoes.length +
notificacoesFerias.length +
notificacoesAusencias.length} notificações
</span>
{#if notificacoesNaoLidas.length > 0}
<span class="text-primary font-semibold">
{notificacoesNaoLidas.length} não lidas
</span>
{/if}
</div>
</div>
{/if}
</div>
{/if}
</div>
<style>
@keyframes badge-bounce {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
}
@keyframes pulse-ring-subtle {
0%,
100% {
opacity: 0.1;
transform: scale(1);
}
50% {
opacity: 0.3;
transform: scale(1.05);
}
}
@keyframes bell-ring {
0%,
100% {
transform: rotate(0deg);
}
10%,
30% {
transform: rotate(-10deg);
}
20%,
40% {
transform: rotate(10deg);
}
50% {
transform: rotate(0deg);
}
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>