feat: enhance notification management with new clearing functionalities

- Added functionality to clear all notifications and clear unread notifications for improved user control.
- Updated NotificationBell component to support modal display for notifications, enhancing user experience.
- Refactored notification handling to separate read and unread notifications, providing clearer organization.
- Introduced new UI elements for managing notifications, including buttons for clearing notifications directly from the modal.
- Improved backend mutations to handle notification deletion securely, ensuring users can only delete their own notifications.
This commit is contained in:
2025-11-05 15:06:41 -03:00
parent 6166043735
commit 1b02ea7c22
3 changed files with 394 additions and 157 deletions

View File

@@ -6,27 +6,38 @@
import { ptBR } from "date-fns/locale";
import { onMount } from "svelte";
import { authStore } from "$lib/stores/auth.svelte";
import { Bell, Mail, AtSign, Users, Calendar, Clock, BellOff } from "lucide-svelte";
import { Bell, Mail, AtSign, Users, Calendar, Clock, BellOff, Trash2, X } from "lucide-svelte";
// Queries e Client
const client = useConvexClient();
const notificacoesQuery = useQuery(api.chat.obterNotificacoes, {
apenasPendentes: true,
});
// 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,
});
let dropdownOpen = $state(false);
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
const count = $derived(
(typeof countQuery === "number" ? countQuery : countQuery?.data) ?? 0
);
const notificacoes = $derived(
(Array.isArray(notificacoesQuery)
? notificacoesQuery
: notificacoesQuery?.data) ?? []
const todasNotificacoes = $derived(
(Array.isArray(todasNotificacoesQuery)
? todasNotificacoesQuery
: todasNotificacoesQuery?.data) ?? []
);
// Separar notificações lidas e não lidas
const notificacoesNaoLidas = $derived(
todasNotificacoes.filter((n) => !n.lida)
);
const notificacoesLidas = $derived(
todasNotificacoes.filter((n) => n.lida)
);
// Atualizar contador no store
@@ -122,16 +133,40 @@
notificacaoId: notif._id,
});
}
dropdownOpen = false;
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,
});
dropdownOpen = false;
}
async function handleClickNotificacaoFerias(notificacaoId: string) {
@@ -139,7 +174,6 @@
notificacaoId: notificacaoId,
});
await buscarNotificacoesFerias();
dropdownOpen = false;
// Redirecionar para a página de férias
window.location.href = "/recursos-humanos/ferias";
}
@@ -149,37 +183,52 @@
notificacaoId: notificacaoId,
});
await buscarNotificacoesAusencias();
dropdownOpen = false;
// Redirecionar para a página de perfil na aba de ausências
window.location.href = "/perfil?aba=minhas-ausencias";
}
function toggleDropdown() {
dropdownOpen = !dropdownOpen;
function openModal() {
modalOpen = true;
}
// Fechar dropdown ao clicar fora
onMount(() => {
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-bell")) {
dropdownOpen = false;
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);
return () => document.removeEventListener("click", handleClickOutside);
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("click", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
};
});
</script>
<div class="dropdown dropdown-end notification-bell">
<div class="notification-bell relative">
<!-- Botão de Notificação ULTRA MODERNO (igual ao perfil) -->
<button
type="button"
tabindex="0"
class="relative flex items-center justify-center w-14 h-14 rounded-2xl overflow-hidden group 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={toggleDropdown}
onclick={openModal}
aria-label="Notificações"
>
<!-- Efeito de brilho no hover -->
@@ -222,155 +271,263 @@
{/if}
</button>
{#if dropdownOpen}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div
tabindex="0"
class="dropdown-content z-50 mt-3 w-80 max-h-96 overflow-auto rounded-box bg-base-100 p-2 shadow-2xl border border-base-300"
>
<!-- Header -->
<div
class="flex items-center justify-between px-4 py-2 border-b border-base-300"
>
<h3 class="text-lg font-semibold">Notificações</h3>
{#if count > 0}
<!-- Popup Flutuante de Notificações -->
{#if modalOpen}
<div class="notification-popup fixed right-4 top-24 z-[100] w-[calc(100vw-2rem)] max-w-2xl max-h-[calc(100vh-7rem)] flex flex-col bg-base-100 rounded-2xl shadow-2xl border border-base-300 overflow-hidden backdrop-blur-sm" style="animation: slideDown 0.2s ease-out;">
<!-- Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-base-300 bg-gradient-to-r from-primary/5 to-primary/10">
<h3 class="text-2xl font-bold text-primary">Notificações</h3>
<div class="flex items-center gap-2">
{#if notificacoesNaoLidas.length > 0}
<button
type="button"
class="btn btn-ghost btn-xs"
onclick={handleMarcarTodasLidas}
class="btn btn-sm btn-ghost"
onclick={handleLimparNotificacoesNaoLidas}
disabled={limpandoNotificacoes}
>
Marcar todas como lidas
<Trash2 class="w-4 h-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="w-4 h-4" />
Limpar todas
</button>
{/if}
<button
type="button"
class="btn btn-sm btn-circle btn-ghost"
onclick={closeModal}
>
<X class="w-5 h-5" />
</button>
</div>
</div>
<!-- Lista de notificações -->
<div class="py-2">
{#if notificacoes.length > 0}
{#each notificacoes.slice(0, 10) as notificacao (notificacao._id)}
<button
type="button"
class="w-full text-left px-4 py-3 hover:bg-base-200 rounded-lg transition-colors"
onclick={() => handleClickNotificacao(notificacao._id)}
>
<div class="flex items-start gap-3">
<!-- Ícone -->
<div class="flex-shrink-0 mt-1">
{#if notificacao.tipo === "nova_mensagem"}
<Mail class="w-5 h-5 text-primary" strokeWidth={1.5} />
{:else if notificacao.tipo === "mencao"}
<AtSign class="w-5 h-5 text-warning" strokeWidth={1.5} />
{:else}
<Users class="w-5 h-5 text-info" strokeWidth={1.5} />
{/if}
</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-sm font-semibold text-primary mb-2 px-2">Não lidas</h4>
{#each notificacoesNaoLidas as notificacao (notificacao._id)}
<button
type="button"
class="w-full text-left px-4 py-3 hover:bg-base-200 rounded-lg transition-colors mb-2 border-l-4 border-primary"
onclick={() => handleClickNotificacao(notificacao._id)}
>
<div class="flex items-start gap-3">
<!-- Ícone -->
<div class="flex-shrink-0 mt-1">
{#if notificacao.tipo === "nova_mensagem"}
<Mail class="w-5 h-5 text-primary" strokeWidth={1.5} />
{:else if notificacao.tipo === "mencao"}
<AtSign class="w-5 h-5 text-warning" strokeWidth={1.5} />
{:else}
<Users class="w-5 h-5 text-info" strokeWidth={1.5} />
{/if}
</div>
<!-- Conteúdo -->
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-base-content">
{notificacao.titulo}
</p>
<p class="text-xs text-base-content/70 truncate">
{notificacao.descricao}
</p>
<p class="text-xs text-base-content/50 mt-1">
{formatarTempo(notificacao.criadaEm)}
</p>
</div>
<!-- Conteúdo -->
<div class="flex-1 min-w-0">
{#if notificacao.tipo === "nova_mensagem" && notificacao.remetente}
<p class="text-sm font-semibold text-primary">
{notificacao.remetente.nome}
</p>
<p class="text-xs text-base-content/70 mt-1 line-clamp-2">
{notificacao.descricao}
</p>
{:else if notificacao.tipo === "mencao" && notificacao.remetente}
<p class="text-sm font-semibold text-warning">
{notificacao.remetente.nome} mencionou você
</p>
<p class="text-xs text-base-content/70 mt-1 line-clamp-2">
{notificacao.descricao}
</p>
{:else}
<p class="text-sm font-semibold text-base-content">
{notificacao.titulo}
</p>
<p class="text-xs text-base-content/70 mt-1 line-clamp-2">
{notificacao.descricao}
</p>
{/if}
<p class="text-xs text-base-content/50 mt-1">
{formatarTempo(notificacao.criadaEm)}
</p>
</div>
<!-- Indicador de não lida -->
{#if !notificacao.lida}
<div class="flex-shrink-0">
<div class="w-2 h-2 rounded-full bg-primary"></div>
<!-- Indicador de não lida -->
<div class="flex-shrink-0">
<div class="w-2 h-2 rounded-full bg-primary"></div>
</div>
</div>
{/if}
</div>
</button>
{/each}
{/if}
<!-- Notificações de Férias -->
{#if notificacoesFerias.length > 0}
{#if notificacoes.length > 0}
<div class="divider my-2 text-xs">Férias</div>
</button>
{/each}
</div>
{/if}
{#each notificacoesFerias.slice(0, 5) as notificacao (notificacao._id)}
<button
type="button"
class="w-full text-left px-4 py-3 hover:bg-base-200 rounded-lg transition-colors"
onclick={() => handleClickNotificacaoFerias(notificacao._id)}
>
<div class="flex items-start gap-3">
<!-- Ícone -->
<div class="flex-shrink-0 mt-1">
<Calendar class="w-5 h-5 text-purple-600" strokeWidth={2} />
</div>
<!-- Conteúdo -->
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-base-content">
{notificacao.mensagem}
</p>
<p class="text-xs text-base-content/50 mt-1">
{formatarTempo(notificacao._creationTime)}
</p>
</div>
<!-- Notificações lidas -->
{#if notificacoesLidas.length > 0}
<div class="mb-4">
<h4 class="text-sm font-semibold text-base-content/60 mb-2 px-2">Lidas</h4>
{#each notificacoesLidas as notificacao (notificacao._id)}
<button
type="button"
class="w-full text-left px-4 py-3 hover:bg-base-200 rounded-lg transition-colors mb-2 opacity-75"
onclick={() => handleClickNotificacao(notificacao._id)}
>
<div class="flex items-start gap-3">
<!-- Ícone -->
<div class="flex-shrink-0 mt-1">
{#if notificacao.tipo === "nova_mensagem"}
<Mail class="w-5 h-5 text-primary/60" strokeWidth={1.5} />
{:else if notificacao.tipo === "mencao"}
<AtSign class="w-5 h-5 text-warning/60" strokeWidth={1.5} />
{:else}
<Users class="w-5 h-5 text-info/60" strokeWidth={1.5} />
{/if}
</div>
<!-- Badge -->
<div class="flex-shrink-0">
<div class="badge badge-primary badge-xs"></div>
</div>
</div>
</button>
{/each}
{/if}
<!-- Notificações de Ausências -->
{#if notificacoesAusencias.length > 0}
{#if notificacoes.length > 0 || notificacoesFerias.length > 0}
<div class="divider my-2 text-xs">Ausências</div>
<!-- Conteúdo -->
<div class="flex-1 min-w-0">
{#if notificacao.tipo === "nova_mensagem" && notificacao.remetente}
<p class="text-sm font-medium text-primary/70">
{notificacao.remetente.nome}
</p>
<p class="text-xs text-base-content/60 mt-1 line-clamp-2">
{notificacao.descricao}
</p>
{:else if notificacao.tipo === "mencao" && notificacao.remetente}
<p class="text-sm font-medium text-warning/70">
{notificacao.remetente.nome} mencionou você
</p>
<p class="text-xs text-base-content/60 mt-1 line-clamp-2">
{notificacao.descricao}
</p>
{:else}
<p class="text-sm font-medium text-base-content/70">
{notificacao.titulo}
</p>
<p class="text-xs text-base-content/60 mt-1 line-clamp-2">
{notificacao.descricao}
</p>
{/if}
<p class="text-xs text-base-content/50 mt-1">
{formatarTempo(notificacao.criadaEm)}
</p>
</div>
</div>
</button>
{/each}
</div>
{/if}
{#each notificacoesAusencias.slice(0, 5) as notificacao (notificacao._id)}
<button
type="button"
class="w-full text-left px-4 py-3 hover:bg-base-200 rounded-lg transition-colors"
onclick={() => handleClickNotificacaoAusencias(notificacao._id)}
>
<div class="flex items-start gap-3">
<!-- Ícone -->
<div class="flex-shrink-0 mt-1">
<Clock class="w-5 h-5 text-orange-600" strokeWidth={2} />
</div>
<!-- Conteúdo -->
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-base-content">
{notificacao.mensagem}
</p>
<p class="text-xs text-base-content/50 mt-1">
{formatarTempo(notificacao._creationTime)}
</p>
</div>
<!-- Notificações de Férias -->
{#if notificacoesFerias.length > 0}
<div class="mb-4">
<h4 class="text-sm font-semibold text-purple-600 mb-2 px-2">Férias</h4>
{#each notificacoesFerias as notificacao (notificacao._id)}
<button
type="button"
class="w-full text-left px-4 py-3 hover:bg-base-200 rounded-lg transition-colors mb-2 border-l-4 border-purple-600"
onclick={() => handleClickNotificacaoFerias(notificacao._id)}
>
<div class="flex items-start gap-3">
<!-- Ícone -->
<div class="flex-shrink-0 mt-1">
<Calendar class="w-5 h-5 text-purple-600" strokeWidth={2} />
</div>
<!-- Badge -->
<div class="flex-shrink-0">
<div class="badge badge-warning badge-xs"></div>
</div>
</div>
</button>
{/each}
{/if}
<!-- Conteúdo -->
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-base-content">
{notificacao.mensagem}
</p>
<p class="text-xs text-base-content/50 mt-1">
{formatarTempo(notificacao._creationTime)}
</p>
</div>
<!-- Sem notificações -->
{#if notificacoes.length === 0 && notificacoesFerias.length === 0 && notificacoesAusencias.length === 0}
<div class="px-4 py-8 text-center text-base-content/50">
<BellOff class="w-12 h-12 mx-auto mb-2 opacity-50" strokeWidth={1.5} />
<p class="text-sm">Nenhuma notificação</p>
<!-- Badge -->
<div class="flex-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="text-sm font-semibold text-orange-600 mb-2 px-2">Ausências</h4>
{#each notificacoesAusencias as notificacao (notificacao._id)}
<button
type="button"
class="w-full text-left px-4 py-3 hover:bg-base-200 rounded-lg transition-colors mb-2 border-l-4 border-orange-600"
onclick={() => handleClickNotificacaoAusencias(notificacao._id)}
>
<div class="flex items-start gap-3">
<!-- Ícone -->
<div class="flex-shrink-0 mt-1">
<Clock class="w-5 h-5 text-orange-600" strokeWidth={2} />
</div>
<!-- Conteúdo -->
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-base-content">
{notificacao.mensagem}
</p>
<p class="text-xs text-base-content/50 mt-1">
{formatarTempo(notificacao._creationTime)}
</p>
</div>
<!-- Badge -->
<div class="flex-shrink-0">
<div class="badge badge-warning badge-xs"></div>
</div>
</div>
</button>
{/each}
</div>
{/if}
{:else}
<!-- Sem notificações -->
<div class="px-4 py-12 text-center text-base-content/50">
<BellOff class="w-16 h-16 mx-auto mb-4 opacity-50" strokeWidth={1.5} />
<p class="text-base font-medium">Nenhuma notificação</p>
<p class="text-sm mt-1">Você está em dia!</p>
</div>
{/if}
</div>
</div>
{/if}
<!-- Footer com estatísticas -->
{#if todasNotificacoes.length > 0 || notificacoesFerias.length > 0 || notificacoesAusencias.length > 0}
<div class="px-6 py-4 border-t border-base-300 bg-base-200/50">
<div class="flex items-center justify-between text-xs text-base-content/60">
<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>
@@ -413,4 +570,16 @@
transform: rotate(0deg);
}
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>