Ajuste chat #9
@@ -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,35 +271,56 @@
|
||||
{/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"
|
||||
>
|
||||
<!-- 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-4 py-2 border-b border-base-300"
|
||||
>
|
||||
<h3 class="text-lg font-semibold">Notificações</h3>
|
||||
{#if count > 0}
|
||||
<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)}
|
||||
<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"
|
||||
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">
|
||||
@@ -267,37 +337,107 @@
|
||||
|
||||
<!-- Conteúdo -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-base-content">
|
||||
{notificacao.titulo}
|
||||
{#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 truncate">
|
||||
<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>
|
||||
</div>
|
||||
{/if}
|
||||
</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}
|
||||
{#if notificacoes.length > 0}
|
||||
<div class="divider my-2 text-xs">Férias</div>
|
||||
{/if}
|
||||
{#each notificacoesFerias.slice(0, 5) as notificacao (notificacao._id)}
|
||||
<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"
|
||||
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">
|
||||
@@ -323,17 +463,17 @@
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/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>
|
||||
{/if}
|
||||
{#each notificacoesAusencias.slice(0, 5) as notificacao (notificacao._id)}
|
||||
<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"
|
||||
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">
|
||||
@@ -359,18 +499,35 @@
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{:else}
|
||||
<!-- 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>
|
||||
<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>
|
||||
@@ -413,4 +570,16 @@
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -208,6 +208,21 @@
|
||||
templates.find((t) => t._id === templateId),
|
||||
);
|
||||
|
||||
// Função para renderizar template com variáveis (similar à função do backend)
|
||||
function renderizarTemplate(
|
||||
template: string,
|
||||
variaveis: Record<string, string>,
|
||||
): string {
|
||||
let resultado = template;
|
||||
|
||||
for (const [chave, valor] of Object.entries(variaveis)) {
|
||||
const placeholder = `{{${chave}}}`;
|
||||
resultado = resultado.replace(new RegExp(placeholder, "g"), valor);
|
||||
}
|
||||
|
||||
return resultado;
|
||||
}
|
||||
|
||||
// Função para mostrar mensagens
|
||||
function mostrarMensagem(tipo: "success" | "error" | "info", texto: string) {
|
||||
mensagem = { tipo, texto };
|
||||
@@ -733,8 +748,11 @@
|
||||
);
|
||||
|
||||
if (conversaId) {
|
||||
const mensagem = usarTemplate
|
||||
? templateSelecionado?.corpo || ""
|
||||
const mensagem = usarTemplate && templateSelecionado
|
||||
? renderizarTemplate(templateSelecionado.corpo, {
|
||||
nome: destinatario.nome,
|
||||
matricula: destinatario.matricula || "",
|
||||
})
|
||||
: mensagemPersonalizada;
|
||||
|
||||
if (agendadaPara) {
|
||||
@@ -988,10 +1006,12 @@
|
||||
);
|
||||
|
||||
if (conversaId) {
|
||||
// Para templates, usar corpo direto (o backend já faz substituição via email)
|
||||
// Para mensagem personalizada, usar diretamente
|
||||
const mensagem = usarTemplate
|
||||
? templateSelecionado?.corpo || ""
|
||||
// Renderizar template com variáveis do destinatário
|
||||
const mensagem = usarTemplate && templateSelecionado
|
||||
? renderizarTemplate(templateSelecionado.corpo, {
|
||||
nome: destinatario.nome,
|
||||
matricula: destinatario.matricula || "",
|
||||
})
|
||||
: mensagemPersonalizada;
|
||||
|
||||
if (agendadaPara) {
|
||||
|
||||
@@ -855,6 +855,54 @@ export const marcarTodasNotificacoesLidas = mutation({
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Deleta todas as notificações do usuário
|
||||
* SEGURANÇA: Usuário só pode deletar suas próprias notificações
|
||||
*/
|
||||
export const limparTodasNotificacoes = mutation({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const usuarioAtual = await getUsuarioAutenticado(ctx);
|
||||
if (!usuarioAtual) throw new Error("Não autenticado");
|
||||
|
||||
const notificacoes = await ctx.db
|
||||
.query("notificacoes")
|
||||
.withIndex("by_usuario", (q) => q.eq("usuarioId", usuarioAtual._id))
|
||||
.collect();
|
||||
|
||||
for (const notificacao of notificacoes) {
|
||||
await ctx.db.delete(notificacao._id);
|
||||
}
|
||||
|
||||
return { excluidas: notificacoes.length };
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Deleta apenas as notificações não lidas do usuário
|
||||
* SEGURANÇA: Usuário só pode deletar suas próprias notificações
|
||||
*/
|
||||
export const limparNotificacoesNaoLidas = mutation({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const usuarioAtual = await getUsuarioAutenticado(ctx);
|
||||
if (!usuarioAtual) throw new Error("Não autenticado");
|
||||
|
||||
const notificacoes = await ctx.db
|
||||
.query("notificacoes")
|
||||
.withIndex("by_usuario_lida", (q) =>
|
||||
q.eq("usuarioId", usuarioAtual._id).eq("lida", false)
|
||||
)
|
||||
.collect();
|
||||
|
||||
for (const notificacao of notificacoes) {
|
||||
await ctx.db.delete(notificacao._id);
|
||||
}
|
||||
|
||||
return { excluidas: notificacoes.length };
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Deleta uma mensagem (soft delete)
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user