530 lines
17 KiB
Svelte
530 lines
17 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import { useQuery, useConvexClient } from 'convex-svelte';
|
|
import { api } from '@sgse-app/backend/convex/_generated/api';
|
|
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
|
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 usuarioId = $derived((currentUser?.data?._id as Id<'usuarios'> | undefined) ?? null);
|
|
let notificacoesFerias = $state<
|
|
Array<{
|
|
_id: Id<'notificacoesFerias'>;
|
|
mensagem: string;
|
|
tipo: string;
|
|
_creationTime: number;
|
|
}>
|
|
>([]);
|
|
let notificacoesAusencias = $state<
|
|
Array<{
|
|
_id: Id<'notificacoesAusencias'>;
|
|
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));
|
|
let totalCount = $derived(count + (notificacoesFerias?.length || 0));
|
|
|
|
// Atualizar contador no store
|
|
$effect(() => {
|
|
const totalNotificacoes =
|
|
count + (notificacoesFerias?.length || 0) + (notificacoesAusencias?.length || 0);
|
|
$notificacoesCount = totalNotificacoes;
|
|
});
|
|
|
|
// Buscar notificações de férias
|
|
async function buscarNotificacoesFerias(id: Id<'usuarios'> | null) {
|
|
try {
|
|
if (!id) return;
|
|
const notifsFerias = await client.query(api.ferias.obterNotificacoesNaoLidas, {
|
|
usuarioId: 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(id: Id<'usuarios'> | null) {
|
|
try {
|
|
if (!id) return;
|
|
try {
|
|
const notifsAusencias = await client.query(api.ausencias.obterNotificacoesNaoLidas, {
|
|
usuarioId: id
|
|
});
|
|
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
|
|
onMount(() => {
|
|
void buscarNotificacoesFerias(usuarioId);
|
|
void buscarNotificacoesAusencias(usuarioId);
|
|
|
|
const interval = setInterval(() => {
|
|
void buscarNotificacoesFerias(usuarioId);
|
|
void buscarNotificacoesAusencias(usuarioId);
|
|
}, 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 handleLimparTodasNotificacoes() {
|
|
limpandoNotificacoes = true;
|
|
try {
|
|
await client.mutation(api.chat.limparTodasNotificacoes, {});
|
|
await buscarNotificacoesFerias(usuarioId);
|
|
await buscarNotificacoesAusencias(usuarioId);
|
|
} 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(usuarioId);
|
|
await buscarNotificacoesAusencias(usuarioId);
|
|
} 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 Id<'notificacoes'>
|
|
});
|
|
}
|
|
|
|
async function handleClickNotificacaoFerias(notificacaoId: Id<'notificacoesFerias'>) {
|
|
await client.mutation(api.ferias.marcarComoLida, {
|
|
notificacaoId: notificacaoId
|
|
});
|
|
await buscarNotificacoesFerias(usuarioId);
|
|
// Redirecionar para a página de férias
|
|
window.location.href = '/recursos-humanos/ferias';
|
|
}
|
|
|
|
async function handleClickNotificacaoAusencias(notificacaoId: Id<'notificacoesAusencias'>) {
|
|
await client.mutation(api.ausencias.marcarComoLida, {
|
|
notificacaoId: notificacaoId
|
|
});
|
|
await buscarNotificacoesAusencias(usuarioId);
|
|
// 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
|
|
onMount(() => {
|
|
function handleClickOutside(event: MouseEvent) {
|
|
if (!modalOpen) return;
|
|
const target = event.target as HTMLElement;
|
|
if (!target.closest('.notification-popup') && !target.closest('.notification-bell')) {
|
|
closeModal();
|
|
}
|
|
}
|
|
|
|
function handleEscape(event: KeyboardEvent) {
|
|
if (!modalOpen) return;
|
|
if (event.key === 'Escape') {
|
|
closeModal();
|
|
}
|
|
}
|
|
|
|
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 (padrão do tema) -->
|
|
<div class="indicator">
|
|
{#if totalCount > 0}
|
|
<span class="indicator-item badge badge-error badge-sm">
|
|
{totalCount > 9 ? '9+' : totalCount}
|
|
</span>
|
|
{/if}
|
|
<button
|
|
type="button"
|
|
tabindex="0"
|
|
class="btn ring-base-200 hover:ring-primary/50 size-10 p-0 ring-2 ring-offset-2 transition-all"
|
|
onclick={openModal}
|
|
aria-label="Notificações"
|
|
aria-expanded={modalOpen}
|
|
>
|
|
<Bell
|
|
class="size-6 transition-colors {totalCount > 0 ? 'text-primary' : 'text-base-content/70'}"
|
|
style="animation: {totalCount > 0 ? 'bell-ring 2s ease-in-out infinite' : 'none'};"
|
|
/>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- 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" 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="text-secondary mb-2 px-2 text-sm font-semibold">Férias</h4>
|
|
{#each notificacoesFerias as notificacao (notificacao._id)}
|
|
<button
|
|
type="button"
|
|
class="hover:bg-base-200 border-secondary mb-2 w-full rounded-lg border-l-4 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="text-secondary h-5 w-5" 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-secondary 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-warning mb-2 px-2 text-sm font-semibold">Ausências</h4>
|
|
{#each notificacoesAusencias as notificacao (notificacao._id)}
|
|
<button
|
|
type="button"
|
|
class="hover:bg-base-200 border-warning mb-2 w-full rounded-lg border-l-4 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="text-warning h-5 w-5" 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 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>
|