- 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.
586 lines
21 KiB
Svelte
586 lines
21 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 { onMount } from "svelte";
|
|
import { authStore } from "$lib/stores/auth.svelte";
|
|
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,
|
|
});
|
|
|
|
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 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
|
|
$effect(() => {
|
|
const totalNotificacoes = count + (notificacoesFerias?.length || 0) + (notificacoesAusencias?.length || 0);
|
|
notificacoesCount.set(totalNotificacoes);
|
|
});
|
|
|
|
// Buscar notificações de férias
|
|
async function buscarNotificacoesFerias() {
|
|
try {
|
|
const usuarioStore = authStore;
|
|
|
|
if (usuarioStore.usuario?._id) {
|
|
const notifsFerias = await client.query(
|
|
api.ferias.obterNotificacoesNaoLidas,
|
|
{
|
|
usuarioId: usuarioStore.usuario._id,
|
|
}
|
|
);
|
|
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 usuarioStore = authStore;
|
|
|
|
if (usuarioStore.usuario?._id) {
|
|
try {
|
|
const notifsAusencias = await client.query(
|
|
api.ausencias.obterNotificacoesNaoLidas,
|
|
{
|
|
usuarioId: usuarioStore.usuario._id,
|
|
}
|
|
);
|
|
notificacoesAusencias = notifsAusencias || [];
|
|
} catch (queryError: unknown) {
|
|
// Silenciar erro se a função não estiver disponível ainda (Convex não sincronizado)
|
|
const errorMessage = queryError instanceof Error ? queryError.message : String(queryError);
|
|
if (!errorMessage.includes("Could not find public function")) {
|
|
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
|
|
const errorMessage = e instanceof Error ? e.message : String(e);
|
|
if (!errorMessage.includes("Could not find public function")) {
|
|
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="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={openModal}
|
|
aria-label="Notificações"
|
|
>
|
|
<!-- Efeito de brilho no hover -->
|
|
<div
|
|
class="absolute inset-0 bg-gradient-to-br from-white/0 to-white/20 opacity-0 group-hover:opacity-100 transition-opacity duration-300"
|
|
></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="absolute inset-0 rounded-2xl bg-error/30 blur-lg animate-pulse"
|
|
></div>
|
|
{/if}
|
|
|
|
<!-- Ícone do sino PREENCHIDO moderno -->
|
|
<Bell
|
|
class="w-7 h-7 text-white relative z-10 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 flex h-6 w-6 items-center justify-center rounded-full text-white text-[10px] font-black shadow-xl ring-2 ring-white z-20"
|
|
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 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-sm btn-ghost"
|
|
onclick={handleLimparNotificacoesNaoLidas}
|
|
disabled={limpandoNotificacoes}
|
|
>
|
|
<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="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">
|
|
{#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 -->
|
|
<div class="flex-shrink-0">
|
|
<div class="w-2 h-2 rounded-full bg-primary"></div>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- 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>
|
|
|
|
<!-- 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}
|
|
|
|
<!-- 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>
|
|
|
|
<!-- 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-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>
|
|
|
|
<!-- 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>
|
|
@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>
|
|
|