593 lines
19 KiB
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>
|