Ajustes gerais #57

Merged
deyvisonwanderley merged 49 commits from ajustes_gerais into master 2025-12-09 18:13:50 +00:00
262 changed files with 49209 additions and 30634 deletions
Showing only changes of commit 1810cbabe2 - Show all commits

View File

@@ -188,7 +188,10 @@
placeholder="Buscar usuários (nome, email, matrícula)..." placeholder="Buscar usuários (nome, email, matrícula)..."
class="input input-bordered w-full pl-10" class="input input-bordered w-full pl-10"
bind:value={searchQuery} bind:value={searchQuery}
aria-label="Buscar usuários ou conversas"
aria-describedby="search-help"
/> />
<span id="search-help" class="sr-only">Digite para buscar usuários por nome, email ou matrícula</span>
<Search <Search
class="text-base-content/50 absolute top-1/2 left-3 h-5 w-5 -translate-y-1/2" class="text-base-content/50 absolute top-1/2 left-3 h-5 w-5 -translate-y-1/2"
strokeWidth={1.5} strokeWidth={1.5}
@@ -244,6 +247,8 @@
: 'cursor-pointer'}" : 'cursor-pointer'}"
onclick={() => handleClickUsuario(usuario)} onclick={() => handleClickUsuario(usuario)}
disabled={processando} disabled={processando}
aria-label="Abrir conversa com {usuario.nome}"
aria-describedby="usuario-status-{usuario._id}"
> >
<!-- Ícone de mensagem --> <!-- Ícone de mensagem -->
<div <div
@@ -260,6 +265,7 @@
fotoPerfilUrl={usuario.fotoPerfilUrl} fotoPerfilUrl={usuario.fotoPerfilUrl}
nome={usuario.nome} nome={usuario.nome}
size="md" size="md"
userId={usuario._id}
/> />
<!-- Status badge --> <!-- Status badge -->
<div class="absolute right-0 bottom-0"> <div class="absolute right-0 bottom-0">
@@ -290,6 +296,9 @@
{usuario.statusMensagem || usuario.email} {usuario.statusMensagem || usuario.email}
</p> </p>
</div> </div>
<span id="usuario-status-{usuario._id}" class="sr-only">
Status: {getStatusLabel(usuario.statusPresenca)}
</span>
</div> </div>
</button> </button>
{/each} {/each}

View File

@@ -7,35 +7,52 @@
minimizarChat, minimizarChat,
maximizarChat, maximizarChat,
abrirChat, abrirChat,
abrirConversa abrirConversa,
notificacaoAtiva
} from '$lib/stores/chatStore'; } from '$lib/stores/chatStore';
import { useQuery } from 'convex-svelte'; import { useQuery } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api'; import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel'; import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import ChatList from './ChatList.svelte'; import ChatList from './ChatList.svelte';
import ChatWindow from './ChatWindow.svelte'; import ChatWindow from './ChatWindow.svelte';
import { MessageCircle, MessageSquare, Minus, Maximize2, X, Bell } from 'lucide-svelte'; import ConnectionIndicator from './ConnectionIndicator.svelte';
import { MessageSquare, Minus, Maximize2, X, Bell } from 'lucide-svelte';
import { SvelteSet } from 'svelte/reactivity'; import { SvelteSet } from 'svelte/reactivity';
const count = useQuery(api.chat.contarNotificacoesNaoLidas, {}); const count = useQuery(api.chat.contarNotificacoesNaoLidas, {});
// Query para verificar o ID do usuário logado (usar como referência) // Query otimizada: usar apenas uma query para obter usuário atual
// Priorizar obterPerfil pois retorna mais informações úteis
const meuPerfilQuery = useQuery(api.usuarios.obterPerfil, {}); const meuPerfilQuery = useQuery(api.usuarios.obterPerfil, {});
// Usuário atual
const currentUser = useQuery(api.auth.getCurrentUser, {}); const currentUser = useQuery(api.auth.getCurrentUser, {});
// Derivar ID do usuário de forma otimizada (usar perfil primeiro, fallback para currentUser)
const meuId = $derived(() => {
if (meuPerfilQuery?.data?._id) {
return String(meuPerfilQuery.data._id).trim();
}
if (currentUser?.data?._id) {
return String(currentUser.data._id).trim();
}
return null;
});
let isOpen = $derived(false); let isOpen = $derived(false);
let isMinimized = $derived(false); let isMinimized = $derived(false);
let activeConversation = $state<string | null>(null); let activeConversation = $state<string | null>(null);
// Função para obter a URL do avatar/foto do usuário logado // Função para obter a URL do avatar/foto do usuário logado (otimizada)
const avatarUrlDoUsuario = $derived(() => { const avatarUrlDoUsuario = $derived(() => {
// Priorizar perfil (tem mais informações)
const perfil = meuPerfilQuery?.data;
if (perfil?.fotoPerfilUrl) {
return perfil.fotoPerfilUrl;
}
// Fallback para currentUser
const usuario = currentUser?.data; const usuario = currentUser?.data;
if (!usuario) return null; if (usuario?.fotoPerfilUrl) {
// Prioridade: fotoPerfilUrl > avatar > fallback com nome
if (usuario.fotoPerfilUrl) {
return usuario.fotoPerfilUrl; return usuario.fotoPerfilUrl;
} }
@@ -52,6 +69,7 @@
let dragThreshold = $state(5); // Distância mínima em pixels para considerar arrastar let dragThreshold = $state(5); // Distância mínima em pixels para considerar arrastar
let hasMoved = $state(false); // Flag para verificar se houve movimento durante o arrastar let hasMoved = $state(false); // Flag para verificar se houve movimento durante o arrastar
let shouldPreventClick = $state(false); // Flag para prevenir clique após arrastar let shouldPreventClick = $state(false); // Flag para prevenir clique após arrastar
let isDoubleClicking = $state(false); // Flag para prevenir clique simples após duplo clique
// Suporte a gestos touch (swipe) // Suporte a gestos touch (swipe)
let touchStart = $state<{ x: number; y: number; time: number } | null>(null); let touchStart = $state<{ x: number; y: number; time: number } | null>(null);
@@ -405,47 +423,29 @@
} }
} }
// Throttle para evitar execuções muito frequentes do effect
let ultimaExecucaoNotificacao = $state(0);
const THROTTLE_NOTIFICACAO_MS = 1000; // 1 segundo entre execuções
$effect(() => { $effect(() => {
if (todasConversas?.data && currentUser?.data?._id) { const agora = Date.now();
const tempoDesdeUltimaExecucao = agora - ultimaExecucaoNotificacao;
// Throttle: só executar se passou tempo suficiente
if (tempoDesdeUltimaExecucao < THROTTLE_NOTIFICACAO_MS && ultimaExecucaoNotificacao > 0) {
return;
}
if (todasConversas?.data && meuId()) {
ultimaExecucaoNotificacao = agora;
const conversas = todasConversas.data as ConversaComTimestamp[]; const conversas = todasConversas.data as ConversaComTimestamp[];
const meuIdAtual = meuId();
// Encontrar conversas com novas mensagens if (!meuIdAtual) {
// Obter ID do usuário logado de forma robusta console.warn('⚠️ [ChatWidget] Não foi possível identificar o ID do usuário logado');
// Prioridade: usar query do Convex (mais confiável) > authStore
const usuarioLogado = currentUser?.data;
const perfilConvex = meuPerfilQuery?.data;
// Usar ID do Convex se disponível, caso contrário usar authStore
let meuId: string | null = null;
if (perfilConvex && perfilConvex._id) {
// Usar ID retornado pela query do Convex (mais confiável)
meuId = String(perfilConvex._id).trim();
} else if (usuarioLogado && usuarioLogado._id) {
// Fallback para authStore
meuId = String(usuarioLogado._id).trim();
}
if (!meuId) {
console.warn('⚠️ [ChatWidget] Não foi possível identificar o ID do usuário logado:', {
currentUser: !!usuarioLogado,
currentUserId: usuarioLogado?._id,
convexPerfil: !!perfilConvex,
convexId: perfilConvex?._id
});
return; return;
} }
// Log para debug (apenas em desenvolvimento)
if (import.meta.env.DEV) {
console.log('🔍 [ChatWidget] Usuário logado identificado:', {
id: meuId,
fonte: perfilConvex ? 'Convex Query' : 'CurrentUser',
nome: usuarioLogado?.nome || perfilConvex?.nome,
email: usuarioLogado?.email
});
}
conversas.forEach((conv) => { conversas.forEach((conv) => {
if (!conv.ultimaMensagemTimestamp) return; if (!conv.ultimaMensagemTimestamp) return;
@@ -455,21 +455,8 @@
? String(conv.ultimaMensagemRemetenteId).trim() ? String(conv.ultimaMensagemRemetenteId).trim()
: null; : null;
// Log para debug da comparação (apenas em desenvolvimento)
if (import.meta.env.DEV && remetenteIdStr) {
const ehMinhaMensagem = remetenteIdStr === meuId;
if (ehMinhaMensagem) {
console.log('✅ [ChatWidget] Mensagem identificada como própria (ignorada):', {
conversaId: conv._id,
meuId,
remetenteId: remetenteIdStr,
mensagem: conv.ultimaMensagem?.substring(0, 50)
});
}
}
// Se a mensagem foi enviada pelo próprio usuário, ignorar completamente // Se a mensagem foi enviada pelo próprio usuário, ignorar completamente
if (remetenteIdStr && remetenteIdStr === meuId) { if (remetenteIdStr && remetenteIdStr === meuIdAtual) {
// Mensagem enviada pelo próprio usuário - não tocar beep nem mostrar notificação // Mensagem enviada pelo próprio usuário - não tocar beep nem mostrar notificação
// Marcar como notificada para evitar processamento futuro // Marcar como notificada para evitar processamento futuro
const mensagemId = `${conv._id}-${conv.ultimaMensagemTimestamp}`; const mensagemId = `${conv._id}-${conv.ultimaMensagemTimestamp}`;
@@ -490,14 +477,29 @@
const conversaIdStr = String(conv._id).trim(); const conversaIdStr = String(conv._id).trim();
const estaConversaEstaAberta = conversaAtivaId === conversaIdStr; const estaConversaEstaAberta = conversaAtivaId === conversaIdStr;
// Verificar se outra notificação já está ativa para esta mensagem
const notificacaoAtual = $notificacaoAtiva;
const jaTemNotificacaoAtiva =
notificacaoAtual &&
notificacaoAtual.conversaId === conversaIdStr &&
notificacaoAtual.mensagemId === mensagemId;
// Só mostrar notificação se: // Só mostrar notificação se:
// 1. O chat não está aberto OU // 1. O chat não está aberto OU
// 2. O chat está aberto mas não estamos vendo essa conversa específica // 2. O chat está aberto mas não estamos vendo essa conversa específica
if (!isOpen || !estaConversaEstaAberta) { // 3. E não há outra notificação ativa para esta mensagem
if ((!isOpen || !estaConversaEstaAberta) && !jaTemNotificacaoAtiva) {
// Marcar como notificada ANTES de mostrar notificação (evita duplicação) // Marcar como notificada ANTES de mostrar notificação (evita duplicação)
mensagensNotificadasGlobal.add(mensagemId); mensagensNotificadasGlobal.add(mensagemId);
salvarMensagensNotificadasGlobal(); salvarMensagensNotificadasGlobal();
// Registrar notificação ativa no store global
notificacaoAtiva.set({
conversaId: conversaIdStr,
mensagemId,
componente: 'widget'
});
// Tocar som de notificação (apenas uma vez) // Tocar som de notificação (apenas uma vez)
tocarSomNotificacaoGlobal(); tocarSomNotificacaoGlobal();
@@ -509,13 +511,17 @@
}; };
showGlobalNotificationPopup = true; showGlobalNotificationPopup = true;
// Ocultar popup após 5 segundos // Ocultar popup após 5 segundos - garantir limpeza
if (globalNotificationTimeout) { if (globalNotificationTimeout) {
clearTimeout(globalNotificationTimeout); clearTimeout(globalNotificationTimeout);
globalNotificationTimeout = null;
} }
globalNotificationTimeout = setTimeout(() => { globalNotificationTimeout = setTimeout(() => {
showGlobalNotificationPopup = false; showGlobalNotificationPopup = false;
globalNotificationMessage = null; globalNotificationMessage = null;
globalNotificationTimeout = null;
// Limpar notificação ativa do store
notificacaoAtiva.set(null);
}, 5000); }, 5000);
} else { } else {
// Chat está aberto e estamos vendo essa conversa - marcar como visualizada // Chat está aberto e estamos vendo essa conversa - marcar como visualizada
@@ -525,6 +531,14 @@
} }
}); });
} }
// Cleanup: limpar timeout quando o effect for desmontado
return () => {
if (globalNotificationTimeout) {
clearTimeout(globalNotificationTimeout);
globalNotificationTimeout = null;
}
};
}); });
function handleToggle() { function handleToggle() {
@@ -583,6 +597,56 @@
maximizarChat(); maximizarChat();
} }
// Handler para duplo clique no botão flutuante - abre e maximiza
function handleDoubleClick() {
// Marcar que estamos processando um duplo clique
isDoubleClicking = true;
// Se o chat estiver fechado ou minimizado, abrir e maximizar
if (!isOpen || isMinimized) {
abrirChat();
// Aguardar um pouco para garantir que o chat foi aberto antes de maximizar
setTimeout(() => {
if (position) {
// Salvar tamanho e posição atuais antes de maximizar
previousSize = { ...windowSize };
previousPosition = { ...position };
// Maximizar completamente
const winWidth =
windowDimensions.width ||
(typeof window !== 'undefined' ? window.innerWidth : DEFAULT_WIDTH);
const winHeight =
windowDimensions.height ||
(typeof window !== 'undefined' ? window.innerHeight : DEFAULT_HEIGHT);
windowSize = {
width: winWidth,
height: winHeight
};
position = {
x: 0,
y: 0
};
isMaximized = true;
saveSize();
ajustarPosicao();
maximizarChat();
}
// Resetar flag após processar
setTimeout(() => {
isDoubleClicking = false;
}, 300);
}, 50);
} else {
// Se já estiver aberto, apenas maximizar
handleMaximize();
setTimeout(() => {
isDoubleClicking = false;
}, 300);
}
}
// Funcionalidade de arrastar // Funcionalidade de arrastar
function handleMouseDown(e: MouseEvent) { function handleMouseDown(e: MouseEvent) {
if (e.button !== 0 || !position) return; // Apenas botão esquerdo if (e.button !== 0 || !position) return; // Apenas botão esquerdo
@@ -929,6 +993,12 @@
}} }}
ontouchstart={handleTouchStart} ontouchstart={handleTouchStart}
onclick={(e) => { onclick={(e) => {
// Prevenir clique simples se estamos processando um duplo clique
if (isDoubleClicking) {
e.preventDefault();
e.stopPropagation();
return;
}
// Só executar toggle se não houve movimento durante o arrastar // Só executar toggle se não houve movimento durante o arrastar
if (!shouldPreventClick && !hasMoved && !isTouching) { if (!shouldPreventClick && !hasMoved && !isTouching) {
handleToggle(); handleToggle();
@@ -939,7 +1009,16 @@
shouldPreventClick = false; // Resetar após prevenir shouldPreventClick = false; // Resetar após prevenir
} }
}} }}
aria-label="Abrir chat" ondblclick={(e) => {
// Prevenir que o clique simples seja executado após o duplo clique
e.preventDefault();
e.stopPropagation();
// Executar maximização apenas se não houve movimento
if (!shouldPreventClick && !hasMoved && !isTouching) {
handleDoubleClick();
}
}}
aria-label="Abrir chat (duplo clique para maximizar)"
> >
<!-- Anel de brilho rotativo melhorado com múltiplas camadas --> <!-- Anel de brilho rotativo melhorado com múltiplas camadas -->
<div <div
@@ -948,8 +1027,21 @@
></div> ></div>
<!-- Segunda camada para efeito de profundidade --> <!-- Segunda camada para efeito de profundidade -->
<div <div
class="absolute inset-0 rounded-full opacity-0 transition-opacity duration-700 group-hover:opacity-60" class="absolute inset-0 rounded-lg opacity-0 transition-opacity duration-700 group-hover:opacity-60 cursor-pointer"
style="background: conic-gradient(from 180deg, transparent 0%, rgba(255,255,255,0.2) 30%, transparent 60%); animation: rotate 4s linear infinite reverse; transform-origin: center;" style="background: conic-gradient(from 180deg, transparent 0%, rgba(255,255,255,0.2) 30%, transparent 60%); animation: rotate 4s linear infinite reverse; transform-origin: center;"
onclick={(e) => {
// Propagar o clique para o elemento pai
e.stopPropagation();
if (!isDoubleClicking && !shouldPreventClick && !hasMoved && !isTouching) {
handleToggle();
}
}}
ondblclick={(e) => {
e.stopPropagation();
if (!shouldPreventClick && !hasMoved && !isTouching) {
handleDoubleClick();
}
}}
></div> ></div>
<!-- Efeito de brilho pulsante durante arrasto --> <!-- Efeito de brilho pulsante durante arrasto -->
{#if isDragging || isTouching} {#if isDragging || isTouching}
@@ -960,8 +1052,8 @@
{/if} {/if}
<!-- Ícone de chat moderno com efeito 3D --> <!-- Ícone de chat moderno com efeito 3D -->
<MessageCircle <MessageSquare
class="relative z-10 h-7 w-7 text-white transition-all duration-500 group-hover:scale-110" class="relative z-10 h-10 w-10 text-white transition-all duration-500 group-hover:scale-110"
style="filter: drop-shadow(0 4px 12px rgba(0,0,0,0.4));" style="filter: drop-shadow(0 4px 12px rgba(0,0,0,0.4));"
strokeWidth={2} strokeWidth={2}
/> />
@@ -1057,7 +1149,7 @@
{#if avatarUrlDoUsuario()} {#if avatarUrlDoUsuario()}
<img <img
src={avatarUrlDoUsuario()} src={avatarUrlDoUsuario()}
alt={currentUser?.data?.nome || 'Usuário'} alt={meuPerfilQuery?.data?.nome || currentUser?.data?.nome || 'Usuário'}
class="h-full w-full object-cover" class="h-full w-full object-cover"
/> />
{:else} {:else}
@@ -1223,6 +1315,9 @@
</div> </div>
{/if} {/if}
<!-- Indicador de Conexão -->
<ConnectionIndicator />
<!-- Popup Global de Notificação de Nova Mensagem (quando chat está fechado/minimizado) --> <!-- Popup Global de Notificação de Nova Mensagem (quando chat está fechado/minimizado) -->
{#if showGlobalNotificationPopup && globalNotificationMessage} {#if showGlobalNotificationPopup && globalNotificationMessage}
{@const notificationMsg = globalNotificationMessage} {@const notificationMsg = globalNotificationMessage}

View File

@@ -25,7 +25,8 @@
XCircle, XCircle,
Phone, Phone,
Video, Video,
ChevronDown ChevronDown,
Search
} from 'lucide-svelte'; } from 'lucide-svelte';
//import { Bell, X, ArrowLeft, LogOut, MoreVertical, Users, Clock, XCircle } from 'lucide-svelte'; //import { Bell, X, ArrowLeft, LogOut, MoreVertical, Users, Clock, XCircle } from 'lucide-svelte';
@@ -46,6 +47,11 @@
let showNotificacaoModal = $state(false); let showNotificacaoModal = $state(false);
let iniciandoChamada = $state(false); let iniciandoChamada = $state(false);
let chamadaAtiva = $state<Id<'chamadas'> | null>(null); let chamadaAtiva = $state<Id<'chamadas'> | null>(null);
let showSearch = $state(false);
let searchQuery = $state('');
let searchResults = $state<Array<any>>([]);
let searching = $state(false);
let selectedSearchResult = $state<number>(-1);
// Estados para modal de erro // Estados para modal de erro
let showErrorModal = $state(false); let showErrorModal = $state(false);
@@ -276,6 +282,7 @@
fotoPerfilUrl={conversa()?.outroUsuario?.fotoPerfilUrl} fotoPerfilUrl={conversa()?.outroUsuario?.fotoPerfilUrl}
nome={conversa()?.outroUsuario?.nome || 'Usuário'} nome={conversa()?.outroUsuario?.nome || 'Usuário'}
size="md" size="md"
userId={conversa()?.outroUsuario?._id}
/> />
{:else} {:else}
<div class="bg-primary/20 flex h-10 w-10 items-center justify-center rounded-full text-xl"> <div class="bg-primary/20 flex h-10 w-10 items-center justify-center rounded-full text-xl">
@@ -361,6 +368,32 @@
<!-- Botões de ação --> <!-- Botões de ação -->
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<!-- Botão de Busca -->
<button
type="button"
class="group relative flex h-9 w-9 items-center justify-center overflow-hidden rounded-lg transition-all duration-300"
style="background: rgba(34, 197, 94, 0.1); border: 1px solid rgba(34, 197, 94, 0.2);"
onclick={(e) => {
e.stopPropagation();
showSearch = !showSearch;
if (!showSearch) {
searchQuery = '';
searchResults = [];
}
}}
aria-label="Buscar mensagens"
title="Buscar mensagens"
aria-expanded={showSearch}
>
<div
class="absolute inset-0 bg-green-500/0 transition-colors duration-300 group-hover:bg-green-500/10"
></div>
<Search
class="relative z-10 h-5 w-5 text-green-500 transition-transform group-hover:scale-110"
strokeWidth={2}
/>
</button>
<!-- Botões de Chamada --> <!-- Botões de Chamada -->
{#if !chamadaAtual && !chamadaAtiva} {#if !chamadaAtual && !chamadaAtiva}
<div class="dropdown dropdown-end"> <div class="dropdown dropdown-end">
@@ -596,6 +629,110 @@
</div> </div>
</div> </div>
<!-- Barra de Busca (quando ativa) -->
{#if showSearch}
<div
class="border-base-300 bg-base-200 flex items-center gap-2 border-b px-4 py-2"
onclick={(e) => e.stopPropagation()}
>
<Search class="text-base-content/50 h-4 w-4" strokeWidth={2} />
<input
type="text"
placeholder="Buscar mensagens nesta conversa..."
class="input input-sm input-bordered flex-1"
bind:value={searchQuery}
onkeydown={handleSearchKeyDown}
aria-label="Buscar mensagens"
aria-describedby="search-results-info"
/>
<button
type="button"
class="btn btn-sm btn-ghost"
onclick={() => {
showSearch = false;
searchQuery = '';
searchResults = [];
}}
aria-label="Fechar busca"
>
<X class="h-4 w-4" />
</button>
</div>
<!-- Resultados da Busca -->
{#if searchQuery.trim().length >= 2}
<div
class="border-base-300 bg-base-200 max-h-64 overflow-y-auto border-b"
role="listbox"
aria-label="Resultados da busca"
id="search-results"
>
{#if searching}
<div class="flex items-center justify-center p-4">
<span class="loading loading-spinner loading-sm"></span>
<span class="text-base-content/50 ml-2 text-sm">Buscando...</span>
</div>
{:else if searchResults.length > 0}
<p id="search-results-info" class="sr-only">
{searchResults.length} resultado{searchResults.length !== 1 ? 's' : ''} encontrado{searchResults.length !== 1
? 's'
: ''}
</p>
{#each searchResults as resultado, index (resultado._id)}
<button
type="button"
class="hover:bg-base-300 flex w-full items-start gap-3 px-4 py-3 text-left transition-colors {index ===
selectedSearchResult
? 'bg-primary/10'
: ''}"
onclick={() => {
window.dispatchEvent(
new CustomEvent('scrollToMessage', {
detail: { mensagemId: resultado._id }
})
);
showSearch = false;
searchQuery = '';
}}
role="option"
aria-selected={index === selectedSearchResult}
aria-label="Mensagem de {resultado.remetente?.nome || 'Usuário'}"
>
<div class="bg-primary/20 flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full">
{#if resultado.remetente?.fotoPerfilUrl}
<img
src={resultado.remetente.fotoPerfilUrl}
alt={resultado.remetente.nome}
class="h-full w-full object-cover"
/>
{:else}
<span class="text-xs font-semibold">
{resultado.remetente?.nome?.charAt(0).toUpperCase() || 'U'}
</span>
{/if}
</div>
<div class="min-w-0 flex-1">
<p class="text-base-content mb-1 text-xs font-semibold">
{resultado.remetente?.nome || 'Usuário'}
</p>
<p class="text-base-content/70 line-clamp-2 text-xs">
{resultado.conteudo}
</p>
<p class="text-base-content/50 mt-1 text-xs">
{new Date(resultado.enviadaEm).toLocaleString('pt-BR')}
</p>
</div>
</button>
{/each}
{:else if searchQuery.trim().length >= 2}
<div class="p-4 text-center">
<p class="text-base-content/50 text-sm">Nenhuma mensagem encontrada</p>
</div>
{/if}
</div>
{/if}
{/if}
<!-- Mensagens --> <!-- Mensagens -->
<div class="min-h-0 flex-1 overflow-hidden"> <div class="min-h-0 flex-1 overflow-hidden">
<MessageList conversaId={conversaId as Id<'conversas'>} /> <MessageList conversaId={conversaId as Id<'conversas'>} />

View File

@@ -0,0 +1,90 @@
<script lang="ts">
import { useConvexClient } from 'convex-svelte';
import { onMount } from 'svelte';
import { Wifi, WifiOff, AlertCircle } from 'lucide-svelte';
const client = useConvexClient();
let isOnline = $state(true);
let convexConnected = $state(true);
let showIndicator = $state(false);
// Detectar status de conexão com internet
function updateOnlineStatus() {
isOnline = navigator.onLine;
showIndicator = !isOnline || !convexConnected;
}
// Detectar status de conexão com Convex
function updateConvexStatus() {
// Verificar se o client está conectado
// O Convex client expõe o status de conexão
const connectionState = (client as any).connectionState?.();
convexConnected = connectionState === 'Connected' || connectionState === 'Connecting';
showIndicator = !isOnline || !convexConnected;
}
onMount(() => {
// Verificar status inicial
updateOnlineStatus();
updateConvexStatus();
// Listeners para mudanças de conexão
window.addEventListener('online', updateOnlineStatus);
window.addEventListener('offline', updateOnlineStatus);
// Verificar status do Convex periodicamente
const interval = setInterval(() => {
updateConvexStatus();
}, 5000);
return () => {
window.removeEventListener('online', updateOnlineStatus);
window.removeEventListener('offline', updateOnlineStatus);
clearInterval(interval);
};
});
// Observar mudanças no client do Convex
$effect(() => {
// Tentar acessar o estado de conexão do Convex
try {
const connectionState = (client as any).connectionState?.();
if (connectionState !== undefined) {
convexConnected = connectionState === 'Connected' || connectionState === 'Connecting';
showIndicator = !isOnline || !convexConnected;
}
} catch {
// Se não conseguir acessar, assumir conectado
convexConnected = true;
}
});
</script>
{#if showIndicator}
<div
class="fixed bottom-4 left-4 z-[99998] flex items-center gap-2 rounded-lg px-3 py-2 shadow-lg transition-all"
class:bg-error={!isOnline || !convexConnected}
class:bg-warning={isOnline && !convexConnected}
class:text-white={!isOnline || !convexConnected}
role="status"
aria-live="polite"
aria-label={!isOnline
? 'Sem conexão com a internet'
: !convexConnected
? 'Reconectando ao servidor'
: 'Conectado'}
>
{#if !isOnline}
<WifiOff class="h-4 w-4" />
<span class="text-sm font-medium">Sem conexão</span>
{:else if !convexConnected}
<AlertCircle class="h-4 w-4" />
<span class="text-sm font-medium">Reconectando...</span>
{:else}
<Wifi class="h-4 w-4" />
<span class="text-sm font-medium">Conectado</span>
{/if}
</div>
{/if}

View File

@@ -28,10 +28,42 @@
const client = useConvexClient(); const client = useConvexClient();
const conversas = useQuery(api.chat.listarConversas, {}); const conversas = useQuery(api.chat.listarConversas, {});
// Constantes de validação
const MAX_MENSAGEM_LENGTH = 5000; // Limite de caracteres por mensagem
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
// Tipos de arquivo permitidos
const TIPOS_PERMITIDOS = {
imagens: ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'],
documentos: [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'text/plain',
'text/csv'
],
arquivos: [
'application/zip',
'application/x-rar-compressed',
'application/x-7z-compressed',
'application/x-tar',
'application/gzip'
]
};
const TODOS_TIPOS_PERMITIDOS = [
...TIPOS_PERMITIDOS.imagens,
...TIPOS_PERMITIDOS.documentos,
...TIPOS_PERMITIDOS.arquivos
];
let mensagem = $state(''); let mensagem = $state('');
let textarea: HTMLTextAreaElement; let textarea: HTMLTextAreaElement;
let enviando = $state(false); let enviando = $state(false);
let uploadingFile = $state(false); let uploadingFile = $state(false);
let uploadProgress = $state(0); // Progresso do upload (0-100)
let digitacaoTimeout: ReturnType<typeof setTimeout> | null = null; let digitacaoTimeout: ReturnType<typeof setTimeout> | null = null;
let showEmojiPicker = $state(false); let showEmojiPicker = $state(false);
let mensagemRespondendo: { let mensagemRespondendo: {
@@ -42,6 +74,8 @@
let showMentionsDropdown = $state(false); let showMentionsDropdown = $state(false);
let mentionQuery = $state(''); let mentionQuery = $state('');
let mentionStartPos = $state(0); let mentionStartPos = $state(0);
let selectedMentionIndex = $state(0); // Índice do participante selecionado no dropdown
let mensagemMuitoLonga = $state(false);
// Emojis mais usados // Emojis mais usados
const emojis = [ const emojis = [
@@ -134,6 +168,19 @@
// Auto-resize do textarea e detectar menções // Auto-resize do textarea e detectar menções
function handleInput(e: Event) { function handleInput(e: Event) {
const target = e.target as HTMLTextAreaElement; const target = e.target as HTMLTextAreaElement;
// Validar tamanho da mensagem
if (mensagem.length > MAX_MENSAGEM_LENGTH) {
mensagemMuitoLonga = true;
// Limitar ao tamanho máximo
mensagem = mensagem.substring(0, MAX_MENSAGEM_LENGTH);
if (textarea) {
textarea.value = mensagem;
}
} else {
mensagemMuitoLonga = false;
}
if (textarea) { if (textarea) {
textarea.style.height = 'auto'; textarea.style.height = 'auto';
textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px'; textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px';
@@ -160,11 +207,13 @@
// Indicador de digitação (debounce de 1s) // Indicador de digitação (debounce de 1s)
if (digitacaoTimeout) { if (digitacaoTimeout) {
clearTimeout(digitacaoTimeout); clearTimeout(digitacaoTimeout);
digitacaoTimeout = null;
} }
digitacaoTimeout = setTimeout(() => { digitacaoTimeout = setTimeout(() => {
if (mensagem.trim()) { if (mensagem.trim()) {
client.mutation(api.chat.indicarDigitacao, { conversaId }); client.mutation(api.chat.indicarDigitacao, { conversaId });
} }
digitacaoTimeout = null;
}, 1000); }, 1000);
} }
@@ -175,6 +224,7 @@
mensagem = antes + `@${nome} ` + depois; mensagem = antes + `@${nome} ` + depois;
showMentionsDropdown = false; showMentionsDropdown = false;
mentionQuery = ''; mentionQuery = '';
selectedMentionIndex = 0; // Resetar índice selecionado
if (textarea) { if (textarea) {
textarea.focus(); textarea.focus();
const newPos = antes.length + nome.length + 2; const newPos = antes.length + nome.length + 2;
@@ -187,6 +237,12 @@
async function handleEnviar() { async function handleEnviar() {
const texto = mensagem.trim(); const texto = mensagem.trim();
if (!texto || enviando) return; if (!texto || enviando) return;
// Validar tamanho antes de enviar
if (texto.length > MAX_MENSAGEM_LENGTH) {
alert(`Mensagem muito longa. O limite é de ${MAX_MENSAGEM_LENGTH} caracteres.`);
return;
}
// Extrair menções do texto (@nome) // Extrair menções do texto (@nome)
const mencoesIds: Id<'usuarios'>[] = []; const mencoesIds: Id<'usuarios'>[] = [];
@@ -270,22 +326,43 @@
window.addEventListener('responderMensagem', handler); window.addEventListener('responderMensagem', handler);
return () => { return () => {
window.removeEventListener('responderMensagem', handler); window.removeEventListener('responderMensagem', handler);
// Limpar timeout de digitação ao desmontar
if (digitacaoTimeout) {
clearTimeout(digitacaoTimeout);
digitacaoTimeout = null;
}
}; };
}); });
function handleKeyDown(e: KeyboardEvent) { function handleKeyDown(e: KeyboardEvent) {
// Navegar dropdown de menções // Navegar dropdown de menções
if (showMentionsDropdown && participantesFiltrados().length > 0) { if (showMentionsDropdown && participantesFiltrados().length > 0) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter') { const participantes = participantesFiltrados();
if (e.key === 'ArrowDown') {
e.preventDefault(); e.preventDefault();
// Implementação simples: selecionar primeiro participante selectedMentionIndex = Math.min(selectedMentionIndex + 1, participantes.length - 1);
if (e.key === 'Enter') { return;
inserirMencao(participantesFiltrados()[0]); }
if (e.key === 'ArrowUp') {
e.preventDefault();
selectedMentionIndex = Math.max(selectedMentionIndex - 1, 0);
return;
}
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
if (participantes[selectedMentionIndex]) {
inserirMencao(participantes[selectedMentionIndex]);
} }
return; return;
} }
if (e.key === 'Escape') { if (e.key === 'Escape') {
e.preventDefault();
showMentionsDropdown = false; showMentionsDropdown = false;
selectedMentionIndex = 0;
return; return;
} }
} }
@@ -302,41 +379,111 @@
const file = input.files?.[0]; const file = input.files?.[0];
if (!file) return; if (!file) return;
// Validar tamanho (max 10MB) // Validar tamanho
if (file.size > 10 * 1024 * 1024) { if (file.size > MAX_FILE_SIZE) {
alert('Arquivo muito grande. O tamanho máximo é 10MB.'); alert(`Arquivo muito grande. O tamanho máximo é ${(MAX_FILE_SIZE / 1024 / 1024).toFixed(0)}MB.`);
input.value = '';
return; return;
} }
// Validar tipo de arquivo
if (!TODOS_TIPOS_PERMITIDOS.includes(file.type)) {
alert(
`Tipo de arquivo não permitido. Tipos aceitos:\n- Imagens: JPEG, PNG, GIF, WebP, SVG\n- Documentos: PDF, Word, Excel, PowerPoint, TXT, CSV\n- Arquivos: ZIP, RAR, 7Z, TAR, GZIP`
);
input.value = '';
return;
}
// Validar extensão do arquivo (segurança adicional)
const extensao = file.name.split('.').pop()?.toLowerCase();
const extensoesPermitidas = [
'jpg',
'jpeg',
'png',
'gif',
'webp',
'svg',
'pdf',
'doc',
'docx',
'xls',
'xlsx',
'ppt',
'pptx',
'txt',
'csv',
'zip',
'rar',
'7z',
'tar',
'gz'
];
if (extensao && !extensoesPermitidas.includes(extensao)) {
alert(`Extensão de arquivo não permitida: .${extensao}`);
input.value = '';
return;
}
// Sanitizar nome do arquivo (remover caracteres perigosos)
const nomeSanitizado = file.name.replace(/[^a-zA-Z0-9._-]/g, '_');
try { try {
uploadingFile = true; uploadingFile = true;
uploadProgress = 0;
// 1. Obter upload URL // 1. Obter upload URL
const uploadUrl = await client.mutation(api.chat.uploadArquivoChat, { const uploadUrl = await client.mutation(api.chat.uploadArquivoChat, {
conversaId conversaId
}); });
// 2. Upload do arquivo // 2. Upload do arquivo com progresso
const result = await fetch(uploadUrl, { const xhr = new XMLHttpRequest();
method: 'POST',
headers: { 'Content-Type': file.type }, // Promise para aguardar upload completo
body: file const uploadPromise = new Promise<string>((resolve, reject) => {
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
uploadProgress = Math.round((e.loaded / e.total) * 100);
}
});
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
const response = JSON.parse(xhr.responseText);
resolve(response.storageId);
} catch {
reject(new Error('Resposta inválida do servidor'));
}
} else {
reject(new Error(`Falha no upload: ${xhr.statusText}`));
}
});
xhr.addEventListener('error', () => {
reject(new Error('Erro de rede durante upload'));
});
xhr.addEventListener('abort', () => {
reject(new Error('Upload cancelado'));
});
xhr.open('POST', uploadUrl);
xhr.setRequestHeader('Content-Type', file.type);
xhr.send(file);
}); });
if (!result.ok) { const storageId = await uploadPromise;
throw new Error('Falha no upload');
}
const { storageId } = await result.json();
// 3. Enviar mensagem com o arquivo // 3. Enviar mensagem com o arquivo
const tipo: 'imagem' | 'arquivo' = file.type.startsWith('image/') ? 'imagem' : 'arquivo'; const tipo: 'imagem' | 'arquivo' = file.type.startsWith('image/') ? 'imagem' : 'arquivo';
await client.mutation(api.chat.enviarMensagem, { await client.mutation(api.chat.enviarMensagem, {
conversaId, conversaId,
conteudo: tipo === 'imagem' ? '' : file.name, conteudo: tipo === 'imagem' ? '' : nomeSanitizado,
tipo, tipo,
arquivoId: storageId, arquivoId: storageId,
arquivoNome: file.name, arquivoNome: nomeSanitizado,
arquivoTamanho: file.size, arquivoTamanho: file.size,
arquivoTipo: file.type arquivoTipo: file.type
}); });
@@ -383,31 +530,48 @@
<div class="flex items-end gap-2"> <div class="flex items-end gap-2">
<!-- Botão de anexar arquivo MODERNO --> <!-- Botão de anexar arquivo MODERNO -->
<label <div class="relative shrink-0">
class="group relative flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-xl transition-all duration-300" <label
style="background: rgba(102, 126, 234, 0.1); border: 1px solid rgba(102, 126, 234, 0.2);" class="group relative flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-xl transition-all duration-300"
title="Anexar arquivo" style="background: rgba(102, 126, 234, 0.1); border: 1px solid rgba(102, 126, 234, 0.2);"
> title="Anexar arquivo"
<input aria-label="Anexar arquivo"
type="file" >
class="hidden" <input
onchange={handleFileUpload} type="file"
disabled={uploadingFile || enviando} class="hidden"
accept="*/*" onchange={handleFileUpload}
/> disabled={uploadingFile || enviando}
<div accept="*/*"
class="bg-primary/0 group-hover:bg-primary/10 absolute inset-0 transition-colors duration-300" aria-label="Selecionar arquivo para anexar"
></div>
{#if uploadingFile}
<span class="loading loading-spinner loading-sm relative z-10"></span>
{:else}
<!-- Ícone de clipe moderno -->
<Paperclip
class="text-primary relative z-10 h-5 w-5 transition-transform group-hover:scale-110"
strokeWidth={2}
/> />
<div
class="bg-primary/0 group-hover:bg-primary/10 absolute inset-0 transition-colors duration-300"
></div>
{#if uploadingFile}
<span class="loading loading-spinner loading-sm relative z-10"></span>
{:else}
<!-- Ícone de clipe moderno -->
<Paperclip
class="text-primary relative z-10 h-5 w-5 transition-transform group-hover:scale-110"
strokeWidth={2}
/>
{/if}
</label>
<!-- Barra de progresso do upload -->
{#if uploadingFile && uploadProgress > 0}
<div
class="absolute -bottom-1 left-0 right-0 h-1 rounded-full bg-base-200"
style="z-index: 20;"
>
<div
class="h-full rounded-full bg-primary transition-all duration-300"
style="width: {uploadProgress}%;"
></div>
</div>
{/if} {/if}
</label> </div>
<!-- Botão de EMOJI MODERNO --> <!-- Botão de EMOJI MODERNO -->
<div class="relative shrink-0"> <div class="relative shrink-0">
@@ -418,6 +582,8 @@
onclick={() => (showEmojiPicker = !showEmojiPicker)} onclick={() => (showEmojiPicker = !showEmojiPicker)}
disabled={enviando || uploadingFile} disabled={enviando || uploadingFile}
aria-label="Adicionar emoji" aria-label="Adicionar emoji"
aria-expanded={showEmojiPicker}
aria-haspopup="true"
title="Adicionar emoji" title="Adicionar emoji"
> >
<div <div
@@ -434,13 +600,18 @@
<div <div
class="bg-base-100 border-base-300 absolute bottom-full left-0 z-50 mb-2 rounded-xl border p-3 shadow-2xl" class="bg-base-100 border-base-300 absolute bottom-full left-0 z-50 mb-2 rounded-xl border p-3 shadow-2xl"
style="width: 280px; max-height: 200px; overflow-y-auto;" style="width: 280px; max-height: 200px; overflow-y-auto;"
role="dialog"
aria-label="Selecionar emoji"
id="emoji-picker"
> >
<div class="grid grid-cols-10 gap-1"> <div class="grid grid-cols-10 gap-1" role="grid">
{#each emojis as emoji} {#each emojis as emoji}
<button <button
type="button" type="button"
class="hover:bg-base-200 cursor-pointer rounded p-1 text-2xl transition-transform hover:scale-125" class="hover:bg-base-200 cursor-pointer rounded p-1 text-2xl transition-transform hover:scale-125"
onclick={() => adicionarEmoji(emoji)} onclick={() => adicionarEmoji(emoji)}
aria-label="Adicionar emoji {emoji}"
role="gridcell"
> >
{emoji} {emoji}
</button> </button>
@@ -458,21 +629,43 @@
oninput={handleInput} oninput={handleInput}
onkeydown={handleKeyDown} onkeydown={handleKeyDown}
placeholder="Digite uma mensagem... (use @ para mencionar)" placeholder="Digite uma mensagem... (use @ para mencionar)"
class="textarea textarea-bordered max-h-[120px] min-h-[44px] w-full resize-none pr-10" class="textarea textarea-bordered max-h-[120px] min-h-[44px] w-full resize-none pr-10 {mensagemMuitoLonga
? 'textarea-error'
: ''}"
rows="1" rows="1"
disabled={enviando || uploadingFile} disabled={enviando || uploadingFile}
maxlength={MAX_MENSAGEM_LENGTH}
aria-label="Campo de mensagem"
aria-describedby="mensagem-help"
aria-invalid={mensagemMuitoLonga}
></textarea> ></textarea>
{#if mensagemMuitoLonga || mensagem.length > MAX_MENSAGEM_LENGTH * 0.9}
<div class="absolute bottom-1 right-2 text-xs {mensagem.length > MAX_MENSAGEM_LENGTH
? 'text-error'
: 'text-base-content/50'}">
{mensagem.length}/{MAX_MENSAGEM_LENGTH}
</div>
{/if}
<!-- Dropdown de Menções --> <!-- Dropdown de Menções -->
{#if showMentionsDropdown && participantesFiltrados().length > 0 && (conversa()?.tipo === 'grupo' || conversa()?.tipo === 'sala_reuniao')} {#if showMentionsDropdown && participantesFiltrados().length > 0 && (conversa()?.tipo === 'grupo' || conversa()?.tipo === 'sala_reuniao')}
<div <div
class="bg-base-100 border-base-300 absolute bottom-full left-0 z-50 mb-2 max-h-48 w-64 overflow-y-auto rounded-lg border shadow-xl" class="bg-base-100 border-base-300 absolute bottom-full left-0 z-50 mb-2 max-h-48 w-64 overflow-y-auto rounded-lg border shadow-xl"
role="listbox"
aria-label="Lista de participantes para mencionar"
id="mentions-dropdown"
> >
{#each participantesFiltrados() as participante (participante._id)} {#each participantesFiltrados() as participante, index (participante._id)}
<button <button
type="button" type="button"
class="hover:bg-base-200 flex w-full items-center gap-2 px-4 py-2 text-left transition-colors" class="hover:bg-base-200 flex w-full items-center gap-2 px-4 py-2 text-left transition-colors {index === selectedMentionIndex
? 'bg-primary/20'
: ''}"
onclick={() => inserirMencao(participante)} onclick={() => inserirMencao(participante)}
role="option"
aria-selected={index === selectedMentionIndex}
aria-label="Mencionar {participante.nome}"
id="mention-option-{index}"
> >
<div <div
class="bg-primary/20 flex h-8 w-8 items-center justify-center overflow-hidden rounded-full" class="bg-primary/20 flex h-8 w-8 items-center justify-center overflow-hidden rounded-full"
@@ -502,14 +695,15 @@
</div> </div>
<!-- Botão de enviar MODERNO --> <!-- Botão de enviar MODERNO -->
<button <button
type="button" type="button"
class="group relative flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-xl transition-all duration-300 disabled:cursor-not-allowed disabled:opacity-50" class="group relative flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-xl transition-all duration-300 disabled:cursor-not-allowed disabled:opacity-50"
style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); box-shadow: 0 8px 24px -4px rgba(102, 126, 234, 0.4);" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); box-shadow: 0 8px 24px -4px rgba(102, 126, 234, 0.4);"
onclick={handleEnviar} onclick={handleEnviar}
disabled={!mensagem.trim() || enviando || uploadingFile} disabled={!mensagem.trim() || enviando || uploadingFile}
aria-label="Enviar" aria-label="Enviar mensagem"
> aria-describedby="mensagem-help"
>
<div <div
class="absolute inset-0 bg-white/0 transition-colors duration-300 group-hover:bg-white/10" class="absolute inset-0 bg-white/0 transition-colors duration-300 group-hover:bg-white/10"
></div> ></div>
@@ -525,7 +719,7 @@
</div> </div>
<!-- Informação sobre atalhos --> <!-- Informação sobre atalhos -->
<p class="text-base-content/50 mt-2 text-center text-xs"> <p id="mensagem-help" class="text-base-content/50 mt-2 text-center text-xs" role="note">
💡 Enter para enviar • Shift+Enter para quebrar linha • 😊 Clique no emoji 💡 Enter para enviar • Shift+Enter para quebrar linha • 😊 Clique no emoji • Use @ para mencionar
</p> </p>
</div> </div>

View File

@@ -6,6 +6,7 @@
import { ptBR } from 'date-fns/locale'; import { ptBR } from 'date-fns/locale';
import { onMount, tick } from 'svelte'; import { onMount, tick } from 'svelte';
import { File, CheckCircle2, CheckCircle, MessageSquare, Bell, X } from 'lucide-svelte'; import { File, CheckCircle2, CheckCircle, MessageSquare, Bell, X } from 'lucide-svelte';
import { notificacaoAtiva } from '$lib/stores/chatStore';
interface Props { interface Props {
conversaId: Id<'conversas'>; conversaId: Id<'conversas'>;
@@ -14,10 +15,45 @@
let { conversaId }: Props = $props(); let { conversaId }: Props = $props();
const client = useConvexClient(); const client = useConvexClient();
const mensagens = useQuery(api.chat.obterMensagens, {
// Estados para paginação
let cursor = $state<Id<'mensagens'> | null>(null);
let todasMensagens = $state<Array<any>>([]);
let carregandoMais = $state(false);
let hasMore = $state(true);
// Query para obter mensagens com paginação
const mensagensQuery = useQuery(api.chat.obterMensagens, {
conversaId, conversaId,
limit: 50 limit: 50,
cursor: cursor || undefined
}); });
// Atualizar lista de mensagens quando a query mudar
$effect(() => {
if (mensagensQuery?.data) {
const resultado = mensagensQuery.data as { mensagens: any[]; hasMore: boolean; nextCursor: Id<'mensagens'> | null };
if (cursor === null) {
// Primeira carga: substituir todas as mensagens
todasMensagens = resultado.mensagens || [];
} else {
// Carregamento adicional: adicionar no início (mensagens mais antigas)
todasMensagens = [...(resultado.mensagens || []), ...todasMensagens];
}
hasMore = resultado.hasMore || false;
carregandoMais = false;
}
});
// Resetar quando mudar de conversa
$effect(() => {
cursor = null;
todasMensagens = [];
hasMore = true;
});
const digitando = useQuery(api.chat.obterDigitando, { conversaId }); const digitando = useQuery(api.chat.obterDigitando, { conversaId });
const isAdmin = useQuery(api.chat.verificarSeEhAdmin, { conversaId }); const isAdmin = useQuery(api.chat.verificarSeEhAdmin, { conversaId });
const conversas = useQuery(api.chat.listarConversas, {}); const conversas = useQuery(api.chat.listarConversas, {});
@@ -54,8 +90,8 @@
mensagensCarregadas = true; mensagensCarregadas = true;
// Marcar todas as mensagens atuais como já visualizadas (não tocar beep ao abrir) // Marcar todas as mensagens atuais como já visualizadas (não tocar beep ao abrir)
if (mensagens?.data && mensagens.data.length > 0) { if (todasMensagens.length > 0) {
mensagens.data.forEach((msg) => { todasMensagens.forEach((msg) => {
mensagensNotificadas.add(String(msg._id)); mensagensNotificadas.add(String(msg._id));
}); });
salvarMensagensNotificadas(); salvarMensagensNotificadas();
@@ -147,16 +183,44 @@
} }
} }
// Função para carregar mais mensagens (scroll infinito)
async function carregarMaisMensagens() {
if (carregandoMais || !hasMore || !mensagensQuery?.data) return;
const resultado = mensagensQuery.data as { mensagens: any[]; hasMore: boolean; nextCursor: Id<'mensagens'> | null };
if (!resultado.nextCursor) return;
carregandoMais = true;
cursor = resultado.nextCursor;
// Aguardar um pouco para a query atualizar
await new Promise(resolve => setTimeout(resolve, 100));
}
// Detectar quando usuário rola para o topo para carregar mais mensagens
function handleScroll(e: Event) {
const target = e.target as HTMLDivElement;
// Considerar "no final" se estiver a menos de 150px do final
const distanciaDoFinal = target.scrollHeight - target.scrollTop - target.clientHeight;
const isAtBottom = distanciaDoFinal < 150;
shouldScrollToBottom = isAtBottom;
// Se está próximo do topo (menos de 200px), carregar mais mensagens
if (target.scrollTop < 200 && hasMore && !carregandoMais) {
carregarMaisMensagens();
}
}
// Auto-scroll para a última mensagem quando novas mensagens chegam // Auto-scroll para a última mensagem quando novas mensagens chegam
// E detectar novas mensagens para tocar som e mostrar popup // E detectar novas mensagens para tocar som e mostrar popup
$effect(() => { $effect(() => {
if (mensagens?.data && messagesContainer) { if (todasMensagens.length > 0 && messagesContainer) {
const currentCount = mensagens.data.length; const currentCount = todasMensagens.length;
const isNewMessage = currentCount > lastMessageCount; const isNewMessage = currentCount > lastMessageCount;
// Detectar nova mensagem de outro usuário // Detectar nova mensagem de outro usuário
if (isNewMessage && mensagens.data.length > 0 && usuarioAtualId) { if (isNewMessage && todasMensagens.length > 0 && usuarioAtualId) {
const ultimaMensagem = mensagens.data[mensagens.data.length - 1]; const ultimaMensagem = todasMensagens[todasMensagens.length - 1];
const mensagemId = String(ultimaMensagem._id); const mensagemId = String(ultimaMensagem._id);
const remetenteIdStr = ultimaMensagem.remetenteId const remetenteIdStr = ultimaMensagem.remetenteId
? String(ultimaMensagem.remetenteId).trim() ? String(ultimaMensagem.remetenteId).trim()
@@ -164,16 +228,33 @@
? String(ultimaMensagem.remetente._id).trim() ? String(ultimaMensagem.remetente._id).trim()
: null; : null;
// Verificar se outra notificação já está ativa para esta mensagem
const notificacaoAtual = $notificacaoAtiva;
const conversaIdStr = String(conversaId).trim();
const jaTemNotificacaoAtiva =
notificacaoAtual &&
notificacaoAtual.conversaId === conversaIdStr &&
notificacaoAtual.mensagemId === mensagemId;
// Se é uma nova mensagem de outro usuário (não minha) E ainda não foi notificada // Se é uma nova mensagem de outro usuário (não minha) E ainda não foi notificada
// E não há outra notificação ativa para esta mensagem
if ( if (
remetenteIdStr && remetenteIdStr &&
remetenteIdStr !== usuarioAtualId && remetenteIdStr !== usuarioAtualId &&
!mensagensNotificadas.has(mensagemId) !mensagensNotificadas.has(mensagemId) &&
!jaTemNotificacaoAtiva
) { ) {
// Marcar como notificada antes de tocar som (evita duplicação) // Marcar como notificada antes de tocar som (evita duplicação)
mensagensNotificadas.add(mensagemId); mensagensNotificadas.add(mensagemId);
salvarMensagensNotificadas(); salvarMensagensNotificadas();
// Registrar notificação ativa no store global
notificacaoAtiva.set({
conversaId: conversaIdStr,
mensagemId,
componente: 'messageList'
});
// Tocar som de notificação (apenas uma vez) // Tocar som de notificação (apenas uma vez)
tocarSomNotificacao(); tocarSomNotificacao();
@@ -186,19 +267,55 @@
}; };
showNotificationPopup = true; showNotificationPopup = true;
// Ocultar popup após 5 segundos // Ocultar popup após 5 segundos - garantir limpeza
if (notificationTimeout) { if (notificationTimeout) {
clearTimeout(notificationTimeout); clearTimeout(notificationTimeout);
notificationTimeout = null;
} }
notificationTimeout = setTimeout(() => { notificationTimeout = setTimeout(() => {
showNotificationPopup = false; showNotificationPopup = false;
notificationMessage = null; notificationMessage = null;
notificationTimeout = null;
// Limpar notificação ativa do store
notificacaoAtiva.set(null);
}, 5000); }, 5000);
} }
} }
if (isNewMessage || shouldScrollToBottom) { // Scroll automático inteligente: só rolar se:
// Usar requestAnimationFrame para garantir que o DOM foi atualizado // 1. É uma nova mensagem E o usuário está no final (ou perto)
// 2. OU o usuário já estava no final antes
if (isNewMessage) {
// Verificar se está no final antes de fazer scroll
if (messagesContainer) {
const distanciaDoFinal =
messagesContainer.scrollHeight -
messagesContainer.scrollTop -
messagesContainer.clientHeight;
const estaNoFinal = distanciaDoFinal < 150;
// Só fazer scroll se estiver no final ou se for minha própria mensagem
const ultimaMensagem = todasMensagens[todasMensagens.length - 1];
const remetenteIdStr = ultimaMensagem.remetenteId
? String(ultimaMensagem.remetenteId).trim()
: ultimaMensagem.remetente?._id
? String(ultimaMensagem.remetente._id).trim()
: null;
const ehMinhaMensagem = remetenteIdStr && remetenteIdStr === usuarioAtualId;
if (estaNoFinal || ehMinhaMensagem || shouldScrollToBottom) {
// Usar requestAnimationFrame para garantir que o DOM foi atualizado
requestAnimationFrame(() => {
tick().then(() => {
if (messagesContainer) {
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
});
});
}
}
} else if (shouldScrollToBottom) {
// Se não é nova mensagem mas o usuário estava no final, manter no final
requestAnimationFrame(() => { requestAnimationFrame(() => {
tick().then(() => { tick().then(() => {
if (messagesContainer) { if (messagesContainer) {
@@ -210,12 +327,20 @@
lastMessageCount = currentCount; lastMessageCount = currentCount;
} }
// Cleanup: limpar timeout quando o effect for desmontado
return () => {
if (notificationTimeout) {
clearTimeout(notificationTimeout);
notificationTimeout = null;
}
};
}); });
// Marcar como lida quando mensagens carregam // Marcar como lida quando mensagens carregam
$effect(() => { $effect(() => {
if (mensagens?.data && mensagens.data.length > 0 && usuarioAtualId) { if (todasMensagens.length > 0 && usuarioAtualId) {
const ultimaMensagem = mensagens.data[mensagens.data.length - 1]; const ultimaMensagem = todasMensagens[todasMensagens.length - 1];
const remetenteIdStr = ultimaMensagem.remetenteId const remetenteIdStr = ultimaMensagem.remetenteId
? String(ultimaMensagem.remetenteId).trim() ? String(ultimaMensagem.remetenteId).trim()
: ultimaMensagem.remetente?._id : ultimaMensagem.remetente?._id
@@ -302,7 +427,9 @@
function handleScroll(e: Event) { function handleScroll(e: Event) {
const target = e.target as HTMLDivElement; const target = e.target as HTMLDivElement;
const isAtBottom = target.scrollHeight - target.scrollTop - target.clientHeight < 100; // Considerar "no final" se estiver a menos de 150px do final
const distanciaDoFinal = target.scrollHeight - target.scrollTop - target.clientHeight;
const isAtBottom = distanciaDoFinal < 150;
shouldScrollToBottom = isAtBottom; shouldScrollToBottom = isAtBottom;
} }
@@ -435,6 +562,32 @@
return false; return false;
} }
// Escutar evento de scroll para mensagem específica (da busca)
onMount(() => {
const handler = (e: Event) => {
const customEvent = e as CustomEvent<{ mensagemId: Id<'mensagens'> }>;
const mensagemId = customEvent.detail.mensagemId;
// Encontrar elemento da mensagem e fazer scroll
if (messagesContainer) {
const mensagemElement = messagesContainer.querySelector(`[data-mensagem-id="${mensagemId}"]`);
if (mensagemElement) {
mensagemElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
// Destacar mensagem temporariamente
mensagemElement.classList.add('bg-yellow-200', 'dark:bg-yellow-900');
setTimeout(() => {
mensagemElement.classList.remove('bg-yellow-200', 'dark:bg-yellow-900');
}, 2000);
}
}
};
window.addEventListener('scrollToMessage', handler);
return () => {
window.removeEventListener('scrollToMessage', handler);
};
});
</script> </script>
<div <div
@@ -442,8 +595,8 @@
bind:this={messagesContainer} bind:this={messagesContainer}
onscroll={handleScroll} onscroll={handleScroll}
> >
{#if mensagens?.data && mensagens.data.length > 0} {#if todasMensagens.length > 0}
{@const gruposPorDia = agruparMensagensPorDia(mensagens.data)} {@const gruposPorDia = agruparMensagensPorDia(todasMensagens)}
{#each Object.entries(gruposPorDia) as [dia, mensagensDia]} {#each Object.entries(gruposPorDia) as [dia, mensagensDia]}
<!-- Separador de dia --> <!-- Separador de dia -->
<div class="my-4 flex items-center justify-center"> <div class="my-4 flex items-center justify-center">
@@ -512,12 +665,25 @@
cancelarEdicao(); cancelarEdicao();
} }
}} }}
aria-label="Editar mensagem"
aria-describedby="edicao-help"
></textarea> ></textarea>
<p id="edicao-help" class="sr-only">Pressione Ctrl+Enter para salvar ou Escape para cancelar</p>
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<button class="btn btn-xs btn-ghost" onclick={cancelarEdicao}> <button
class="btn btn-xs btn-ghost"
onclick={cancelarEdicao}
aria-label="Cancelar edição"
>
Cancelar Cancelar
</button> </button>
<button class="btn btn-xs btn-primary" onclick={salvarEdicao}> Salvar </button> <button
class="btn btn-xs btn-primary"
onclick={salvarEdicao}
aria-label="Salvar edição"
>
Salvar
</button>
</div> </div>
</div> </div>
{:else if mensagem.deletada} {:else if mensagem.deletada}
@@ -625,6 +791,7 @@
class="text-base-content/50 hover:text-primary mt-1 text-xs transition-colors" class="text-base-content/50 hover:text-primary mt-1 text-xs transition-colors"
onclick={() => responderMensagem(mensagem)} onclick={() => responderMensagem(mensagem)}
title="Responder" title="Responder"
aria-label="Responder à mensagem de {mensagem.remetente?.nome || 'usuário'}"
> >
↪️ Responder ↪️ Responder
</button> </button>
@@ -663,6 +830,7 @@
class="text-base-content/50 hover:text-primary text-xs transition-colors" class="text-base-content/50 hover:text-primary text-xs transition-colors"
onclick={() => editarMensagem(mensagem)} onclick={() => editarMensagem(mensagem)}
title="Editar mensagem" title="Editar mensagem"
aria-label="Editar esta mensagem"
> >
✏️ ✏️
</button> </button>
@@ -670,6 +838,7 @@
class="text-base-content/50 hover:text-error text-xs transition-colors" class="text-base-content/50 hover:text-error text-xs transition-colors"
onclick={() => deletarMensagem(mensagem._id, false)} onclick={() => deletarMensagem(mensagem._id, false)}
title="Deletar mensagem" title="Deletar mensagem"
aria-label="Deletar esta mensagem"
> >
🗑️ 🗑️
</button> </button>
@@ -679,6 +848,7 @@
class="text-base-content/50 hover:text-error text-xs transition-colors" class="text-base-content/50 hover:text-error text-xs transition-colors"
onclick={() => deletarMensagem(mensagem._id, true)} onclick={() => deletarMensagem(mensagem._id, true)}
title="Deletar mensagem (como administrador)" title="Deletar mensagem (como administrador)"
aria-label="Deletar esta mensagem como administrador"
> >
🗑️ Admin 🗑️ Admin
</button> </button>
@@ -711,7 +881,22 @@
</p> </p>
</div> </div>
{/if} {/if}
{:else if !mensagens?.data}
<!-- Indicador de carregamento de mais mensagens -->
{#if carregandoMais}
<div class="my-4 flex items-center justify-center">
<span class="loading loading-spinner loading-sm"></span>
<span class="text-base-content/50 ml-2 text-xs">Carregando mensagens anteriores...</span>
</div>
{/if}
<!-- Indicador de fim das mensagens -->
{#if !hasMore && todasMensagens.length > 0}
<div class="my-4 flex items-center justify-center">
<span class="text-base-content/50 text-xs">Não há mais mensagens</span>
</div>
{/if}
{:else if !mensagensQuery?.data && todasMensagens.length === 0}
<!-- Loading --> <!-- Loading -->
<div class="flex h-full items-center justify-center"> <div class="flex h-full items-center justify-center">
<span class="loading loading-spinner loading-lg"></span> <span class="loading loading-spinner loading-lg"></span>

View File

@@ -82,19 +82,25 @@
}); });
notificacoesAusencias = notifsAusencias || []; notificacoesAusencias = notifsAusencias || [];
} catch (queryError: unknown) { } catch (queryError: unknown) {
// Silenciar erro se a função não estiver disponível ainda (Convex não sincronizado) // Silenciar erros de timeout e função não encontrada
const errorMessage = const errorMessage =
queryError instanceof Error ? queryError.message : String(queryError); queryError instanceof Error ? queryError.message : String(queryError);
if (!errorMessage.includes('Could not find public function')) { 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); console.error('Erro ao buscar notificações de ausências:', queryError);
} }
notificacoesAusencias = []; notificacoesAusencias = [];
} }
} }
} catch (e) { } catch (e) {
// Erro geral - silenciar se for sobre função não encontrada // Erro geral - silenciar se for sobre função não encontrada ou timeout
const errorMessage = e instanceof Error ? e.message : String(e); const errorMessage = e instanceof Error ? e.message : String(e);
if (!errorMessage.includes('Could not find public function')) { 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); console.error('Erro ao buscar notificações de ausências:', e);
} }
} }

View File

@@ -17,6 +17,47 @@
let heartbeatInterval: ReturnType<typeof setInterval> | null = null; let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
let inactivityTimeout: ReturnType<typeof setTimeout> | null = null; let inactivityTimeout: ReturnType<typeof setTimeout> | null = null;
let lastActivity = Date.now(); let lastActivity = Date.now();
let lastStatusUpdate = 0;
let pendingStatusUpdate: ReturnType<typeof setTimeout> | null = null;
const STATUS_UPDATE_THROTTLE = 5000; // 5 segundos entre atualizações
// Função auxiliar para atualizar status com throttle e tratamento de erro
async function atualizarStatusPresencaSeguro(status: 'online' | 'offline' | 'ausente' | 'externo' | 'em_reuniao') {
if (!usuarioAutenticado) return;
const now = Date.now();
// Throttle: só atualizar se passou tempo suficiente desde a última atualização
if (now - lastStatusUpdate < STATUS_UPDATE_THROTTLE) {
// Cancelar atualização pendente se houver
if (pendingStatusUpdate) {
clearTimeout(pendingStatusUpdate);
}
// Agendar atualização para depois do throttle
pendingStatusUpdate = setTimeout(() => {
atualizarStatusPresencaSeguro(status);
}, STATUS_UPDATE_THROTTLE - (now - lastStatusUpdate));
return;
}
// Limpar atualização pendente se houver
if (pendingStatusUpdate) {
clearTimeout(pendingStatusUpdate);
pendingStatusUpdate = null;
}
lastStatusUpdate = now;
try {
await client.mutation(api.chat.atualizarStatusPresenca, { status });
} catch (error) {
// Silenciar erros de timeout - não são críticos para a funcionalidade
const errorMessage = error instanceof Error ? error.message : String(error);
const isTimeout = errorMessage.includes('timed out') || errorMessage.includes('timeout');
if (!isTimeout) {
console.error('Erro ao atualizar status de presença:', error);
}
}
}
// Detectar atividade do usuário // Detectar atividade do usuário
function handleActivity() { function handleActivity() {
@@ -33,7 +74,7 @@
inactivityTimeout = setTimeout( inactivityTimeout = setTimeout(
() => { () => {
if (usuarioAutenticado) { if (usuarioAutenticado) {
client.mutation(api.chat.atualizarStatusPresenca, { status: 'ausente' }); atualizarStatusPresencaSeguro('ausente');
} }
}, },
5 * 60 * 1000 5 * 60 * 1000
@@ -45,7 +86,7 @@
if (!usuarioAutenticado) return; if (!usuarioAutenticado) return;
// Configurar como online ao montar (apenas se autenticado) // Configurar como online ao montar (apenas se autenticado)
client.mutation(api.chat.atualizarStatusPresenca, { status: 'online' }); atualizarStatusPresencaSeguro('online');
// Heartbeat a cada 30 segundos (apenas se autenticado) // Heartbeat a cada 30 segundos (apenas se autenticado)
heartbeatInterval = setInterval(() => { heartbeatInterval = setInterval(() => {
@@ -61,7 +102,7 @@
// Se houve atividade nos últimos 5 minutos, manter online // Se houve atividade nos últimos 5 minutos, manter online
if (timeSinceLastActivity < 5 * 60 * 1000) { if (timeSinceLastActivity < 5 * 60 * 1000) {
client.mutation(api.chat.atualizarStatusPresenca, { status: 'online' }); atualizarStatusPresencaSeguro('online');
} }
}, 30 * 1000); }, 30 * 1000);
@@ -82,10 +123,10 @@
if (document.hidden) { if (document.hidden) {
// Aba ficou inativa // Aba ficou inativa
client.mutation(api.chat.atualizarStatusPresenca, { status: 'ausente' }); atualizarStatusPresencaSeguro('ausente');
} else { } else {
// Aba ficou ativa // Aba ficou ativa
client.mutation(api.chat.atualizarStatusPresenca, { status: 'online' }); atualizarStatusPresencaSeguro('online');
handleActivity(); handleActivity();
} }
} }
@@ -94,9 +135,15 @@
// Cleanup // Cleanup
return () => { return () => {
// Limpar atualização pendente
if (pendingStatusUpdate) {
clearTimeout(pendingStatusUpdate);
pendingStatusUpdate = null;
}
// Marcar como offline ao desmontar (apenas se autenticado) // Marcar como offline ao desmontar (apenas se autenticado)
if (usuarioAutenticado) { if (usuarioAutenticado) {
client.mutation(api.chat.atualizarStatusPresenca, { status: 'offline' }); atualizarStatusPresencaSeguro('offline');
} }
if (heartbeatInterval) { if (heartbeatInterval) {

View File

@@ -1,13 +1,55 @@
<script lang="ts"> <script lang="ts">
import { User } from 'lucide-svelte'; import { User } from 'lucide-svelte';
import { getCachedAvatar } from '$lib/utils/avatarCache';
import { onMount } from 'svelte';
interface Props { interface Props {
fotoPerfilUrl?: string | null; fotoPerfilUrl?: string | null;
nome: string; nome: string;
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'; size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
userId?: string; // ID do usuário para cache
} }
let { fotoPerfilUrl, nome, size = 'md' }: Props = $props(); let { fotoPerfilUrl, nome, size = 'md', userId }: Props = $props();
let cachedAvatarUrl = $state<string | null>(null);
let loading = $state(true);
onMount(async () => {
if (fotoPerfilUrl) {
loading = true;
try {
cachedAvatarUrl = await getCachedAvatar(fotoPerfilUrl, userId);
} catch (error) {
console.warn('Erro ao carregar avatar:', error);
cachedAvatarUrl = null;
} finally {
loading = false;
}
} else {
loading = false;
}
});
// Atualizar quando fotoPerfilUrl mudar
$effect(() => {
if (fotoPerfilUrl) {
loading = true;
getCachedAvatar(fotoPerfilUrl, userId)
.then((url) => {
cachedAvatarUrl = url;
loading = false;
})
.catch((error) => {
console.warn('Erro ao carregar avatar:', error);
cachedAvatarUrl = null;
loading = false;
});
} else {
cachedAvatarUrl = null;
loading = false;
}
});
const sizeClasses = { const sizeClasses = {
xs: 'w-8 h-8', xs: 'w-8 h-8',
@@ -30,11 +72,25 @@
<div <div
class={`${sizeClasses[size]} bg-base-200 text-base-content/50 flex items-center justify-center overflow-hidden rounded-full`} class={`${sizeClasses[size]} bg-base-200 text-base-content/50 flex items-center justify-center overflow-hidden rounded-full`}
> >
{#if fotoPerfilUrl} {#if loading}
<span class="loading loading-spinner loading-xs"></span>
{:else if cachedAvatarUrl}
<img
src={cachedAvatarUrl}
alt={`Foto de perfil de ${nome}`}
class="h-full w-full object-cover"
loading="lazy"
onerror={() => {
cachedAvatarUrl = null;
}}
/>
{:else if fotoPerfilUrl}
<!-- Fallback: usar URL original se cache falhar -->
<img <img
src={fotoPerfilUrl} src={fotoPerfilUrl}
alt={`Foto de perfil de ${nome}`} alt={`Foto de perfil de ${nome}`}
class="h-full w-full object-cover" class="h-full w-full object-cover"
loading="lazy"
/> />
{:else} {:else}
<User size={iconSizes[size]} /> <User size={iconSizes[size]} />

View File

@@ -308,7 +308,7 @@
async function measureLatency(): Promise<number> { async function measureLatency(): Promise<number> {
const start = performance.now(); const start = performance.now();
try { try {
await fetch(window.location.origin + '/favicon.ico', { await fetch(window.location.origin + '/favicon.png', {
method: 'HEAD', method: 'HEAD',
cache: 'no-cache' cache: 'no-cache'
}); });

View File

@@ -11,6 +11,14 @@ export const chatMinimizado = writable<boolean>(false);
// Store para o contador de notificações // Store para o contador de notificações
export const notificacoesCount = writable<number>(0); export const notificacoesCount = writable<number>(0);
// Store para coordenar notificações entre componentes (evitar duplicação)
// Quando uma notificação é mostrada em um componente, os outros devem ignorar
export const notificacaoAtiva = writable<{
conversaId: string;
mensagemId: string;
componente: 'widget' | 'messageList';
} | null>(null);
// Funções auxiliares // Funções auxiliares
export function abrirChat() { export function abrirChat() {
chatAberto.set(true); chatAberto.set(true);

View File

@@ -0,0 +1,142 @@
/**
* Cache de avatares para reduzir requisições repetidas
* Usa Cache API do navegador e cache em memória
*/
interface AvatarCacheEntry {
url: string;
timestamp: number;
}
// Cache em memória (útil durante a sessão)
const memoryCache = new Map<string, AvatarCacheEntry>();
// Nome do cache no Cache API
const CACHE_NAME = 'sgse-avatars-v1';
const CACHE_DURATION = 7 * 24 * 60 * 60 * 1000; // 7 dias
/**
* Obtém avatar do cache ou faz requisição
*/
export async function getCachedAvatar(
avatarUrl: string | null | undefined,
userId?: string
): Promise<string | null> {
if (!avatarUrl) return null;
// Usar userId como chave se disponível, senão usar a URL
const cacheKey = userId || avatarUrl;
// Verificar cache em memória primeiro
const memoryEntry = memoryCache.get(cacheKey);
if (memoryEntry && Date.now() - memoryEntry.timestamp < CACHE_DURATION) {
return memoryEntry.url;
}
// Verificar Cache API do navegador
if (typeof window !== 'undefined' && 'caches' in window) {
try {
const cache = await caches.open(CACHE_NAME);
const cachedResponse = await cache.match(avatarUrl);
if (cachedResponse) {
const blob = await cachedResponse.blob();
const url = URL.createObjectURL(blob);
// Atualizar cache em memória
memoryCache.set(cacheKey, {
url,
timestamp: Date.now()
});
return url;
}
} catch (error) {
console.warn('Erro ao acessar cache de avatares:', error);
}
}
// Se não está no cache, fazer requisição e armazenar
try {
const response = await fetch(avatarUrl);
if (!response.ok) return null;
const blob = await response.blob();
const url = URL.createObjectURL(blob);
// Armazenar no cache em memória
memoryCache.set(cacheKey, {
url,
timestamp: Date.now()
});
// Armazenar no Cache API
if (typeof window !== 'undefined' && 'caches' in window) {
try {
const cache = await caches.open(CACHE_NAME);
await cache.put(avatarUrl, new Response(blob));
} catch (error) {
console.warn('Erro ao armazenar avatar no cache:', error);
}
}
return url;
} catch (error) {
console.warn('Erro ao carregar avatar:', error);
return null;
}
}
/**
* Limpa o cache de avatares
*/
export async function clearAvatarCache(): Promise<void> {
memoryCache.clear();
if (typeof window !== 'undefined' && 'caches' in window) {
try {
await caches.delete(CACHE_NAME);
} catch (error) {
console.warn('Erro ao limpar cache de avatares:', error);
}
}
}
/**
* Limpa avatares antigos do cache (mais de 7 dias)
*/
export async function cleanOldAvatars(): Promise<void> {
const now = Date.now();
// Limpar cache em memória
for (const [key, entry] of memoryCache.entries()) {
if (now - entry.timestamp > CACHE_DURATION) {
URL.revokeObjectURL(entry.url);
memoryCache.delete(key);
}
}
// Limpar Cache API (manter apenas os últimos 100)
if (typeof window !== 'undefined' && 'caches' in window) {
try {
const cache = await caches.open(CACHE_NAME);
const keys = await cache.keys();
// Se há mais de 100 avatares, remover os mais antigos
if (keys.length > 100) {
const toDelete = keys.slice(0, keys.length - 100);
await Promise.all(toDelete.map((key) => cache.delete(key)));
}
} catch (error) {
console.warn('Erro ao limpar cache antigo:', error);
}
}
}
// Limpar cache antigo periodicamente (a cada hora)
if (typeof window !== 'undefined') {
setInterval(() => {
cleanOldAvatars();
}, 60 * 60 * 1000);
}

View File

@@ -434,3 +434,5 @@ export function adicionarRodape(doc: jsPDF): void {
} }

View File

@@ -77,7 +77,7 @@ async function measureNetworkLatency(): Promise<number> {
const start = performance.now(); const start = performance.now();
// Fazer uma requisição pequena para medir latência // Fazer uma requisição pequena para medir latência
await fetch(window.location.origin + '/favicon.ico', { await fetch(window.location.origin + '/favicon.png', {
method: 'HEAD', method: 'HEAD',
cache: 'no-cache' cache: 'no-cache'
}); });

View File

@@ -97,6 +97,11 @@ export async function gerarPDFComSelecao(
yPosition = adicionarDadosFuncionario(doc, yPosition, funcionario, dataInicio, dataFim); yPosition = adicionarDadosFuncionario(doc, yPosition, funcionario, dataInicio, dataFim);
} }
// SEÇÃO: TABELA PRINCIPAL DE REGISTROS (PRIMEIRO)
if (sections.registrosPonto) {
yPosition = gerarTabelaRegistrosPDF(doc, yPosition, dias, configPonto, sections);
}
// Resumo do Período // Resumo do Período
yPosition = adicionarResumoPeriodo(doc, yPosition, resumo, formatarHoras, formatarMinutos); yPosition = adicionarResumoPeriodo(doc, yPosition, resumo, formatarHoras, formatarMinutos);
@@ -106,11 +111,6 @@ export async function gerarPDFComSelecao(
// Legenda // Legenda
yPosition = adicionarLegenda(doc, yPosition); yPosition = adicionarLegenda(doc, yPosition);
// SEÇÃO: TABELA PRINCIPAL DE REGISTROS
if (sections.registrosPonto) {
yPosition = gerarTabelaRegistrosPDF(doc, yPosition, dias, configPonto, sections);
}
// SEÇÃO: BANCO DE HORAS // SEÇÃO: BANCO DE HORAS
if (sections.bancoHoras) { if (sections.bancoHoras) {
yPosition = await gerarSecaoBancoHorasPDF( yPosition = await gerarSecaoBancoHorasPDF(

View File

@@ -73,3 +73,5 @@

View File

@@ -132,6 +132,12 @@
const funcionarios = $derived(funcionariosQuery?.data || []); const funcionarios = $derived(funcionariosQuery?.data || []);
const registros = $derived(registrosQuery?.data || []); const registros = $derived(registrosQuery?.data || []);
const estatisticas = $derived(estatisticasQuery?.data); const estatisticas = $derived(estatisticasQuery?.data);
// Obter nome do funcionário selecionado
const funcionarioSelecionadoNome = $derived.by(() => {
if (!funcionarioIdFiltro) return null;
return funcionarios.find((f) => f._id === funcionarioIdFiltro)?.nome || null;
});
const config = $derived(configQuery?.data); const config = $derived(configQuery?.data);
// Debug: Log dos dados recebidos // Debug: Log dos dados recebidos
@@ -585,12 +591,7 @@
// Funções de formatação importadas de $lib/utils/ponto/formatacao // Funções de formatação importadas de $lib/utils/ponto/formatacao
// Usar função centralizada formatarDataDDMMAAAA da lib/utils/ponto.ts // Usar função centralizada formatarDataDDMMAAAA da lib/utils/ponto.ts
// funcionarioSelecionadoNome movido para cima, logo após a definição de funcionarios
// Obter nome do funcionário selecionado
const funcionarioSelecionadoNome = $derived.by(() => {
if (!funcionarioIdFiltro) return null;
return funcionarios.find((f) => f._id === funcionarioIdFiltro)?.nome || null;
});
// Funções de cálculo importadas de $lib/utils/ponto/calculos // Funções de cálculo importadas de $lib/utils/ponto/calculos
// calcularSaldosParciais, calcularSaldoDiario, calcularSaldosPorPar, calcularSaldoComparativoPorPar // calcularSaldosParciais, calcularSaldoDiario, calcularSaldosPorPar, calcularSaldoComparativoPorPar

View File

@@ -73,3 +73,5 @@

View File

@@ -69,8 +69,9 @@ export async function verificarLicencaAtiva(
dataAtual?: Date dataAtual?: Date
): Promise<boolean> { ): Promise<boolean> {
// Normalizar data atual para comparar apenas a parte da data (sem hora) // Normalizar data atual para comparar apenas a parte da data (sem hora)
// Usar timezone local para evitar problemas de conversão
const hoje = dataAtual || new Date(); const hoje = dataAtual || new Date();
const hojeStr = hoje.toISOString().split('T')[0]; // Formato: "YYYY-MM-DD" const hojeStr = `${hoje.getFullYear()}-${String(hoje.getMonth() + 1).padStart(2, '0')}-${String(hoje.getDate()).padStart(2, '0')}`; // Formato: "YYYY-MM-DD"
console.log( console.log(
`[verificarLicencaAtiva] Verificando funcionário ${funcionarioId}, data atual: ${hojeStr}` `[verificarLicencaAtiva] Verificando funcionário ${funcionarioId}, data atual: ${hojeStr}`
@@ -966,9 +967,13 @@ export const criarDeclaracaoComparecimento = mutation({
await recalcularBancoHorasPeriodo(ctx, args.funcionarioId, args.dataInicio, args.dataFim); await recalcularBancoHorasPeriodo(ctx, args.funcionarioId, args.dataInicio, args.dataFim);
// Atualizar status do funcionário imediatamente // Atualizar status do funcionário imediatamente
console.log(
`[criarDeclaracaoComparecimento] Atualizando status do funcionário ${args.funcionarioId} após criar declaração`
);
await ctx.runMutation(internal.ferias.atualizarStatusFuncionario, { await ctx.runMutation(internal.ferias.atualizarStatusFuncionario, {
funcionarioId: args.funcionarioId funcionarioId: args.funcionarioId
}); });
console.log(`[criarDeclaracaoComparecimento] Status atualizado com sucesso`);
return atestadoId; return atestadoId;
} }
@@ -1027,9 +1032,13 @@ export const criarLicencaMaternidade = mutation({
await recalcularBancoHorasPeriodo(ctx, args.funcionarioId, args.dataInicio, args.dataFim); await recalcularBancoHorasPeriodo(ctx, args.funcionarioId, args.dataInicio, args.dataFim);
// Atualizar status do funcionário imediatamente // Atualizar status do funcionário imediatamente
console.log(
`[criarLicencaMaternidade] Atualizando status do funcionário ${args.funcionarioId} após criar licença maternidade`
);
await ctx.runMutation(internal.ferias.atualizarStatusFuncionario, { await ctx.runMutation(internal.ferias.atualizarStatusFuncionario, {
funcionarioId: args.funcionarioId funcionarioId: args.funcionarioId
}); });
console.log(`[criarLicencaMaternidade] Status atualizado com sucesso`);
return licencaId; return licencaId;
} }
@@ -1081,9 +1090,13 @@ export const criarLicencaPaternidade = mutation({
await recalcularBancoHorasPeriodo(ctx, args.funcionarioId, args.dataInicio, args.dataFim); await recalcularBancoHorasPeriodo(ctx, args.funcionarioId, args.dataInicio, args.dataFim);
// Atualizar status do funcionário imediatamente // Atualizar status do funcionário imediatamente
console.log(
`[criarLicencaPaternidade] Atualizando status do funcionário ${args.funcionarioId} após criar licença paternidade`
);
await ctx.runMutation(internal.ferias.atualizarStatusFuncionario, { await ctx.runMutation(internal.ferias.atualizarStatusFuncionario, {
funcionarioId: args.funcionarioId funcionarioId: args.funcionarioId
}); });
console.log(`[criarLicencaPaternidade] Status atualizado com sucesso`);
return licencaId; return licencaId;
} }
@@ -1165,6 +1178,13 @@ export const excluirAtestado = mutation({
const atestado = await ctx.db.get(args.id); const atestado = await ctx.db.get(args.id);
if (!atestado) throw new Error('Atestado não encontrado'); if (!atestado) throw new Error('Atestado não encontrado');
// IMPORTANTE: Salvar o período exato do atestado ANTES de excluir
// para recalcular o banco de horas apenas para esse período específico
const funcionarioId = atestado.funcionarioId;
const dataInicio = atestado.dataInicio; // Data início do atestado
const dataFim = atestado.dataFim; // Data fim do atestado
// Excluir o registro do banco de dados
await ctx.db.delete(args.id); await ctx.db.delete(args.id);
await registrarAtividade( await registrarAtividade(
@@ -1176,9 +1196,13 @@ export const excluirAtestado = mutation({
args.id args.id
); );
// Recalcular banco de horas APENAS para o período específico do atestado excluído
// Isso garante que os dias do atestado sejam removidos corretamente dos registros de ponto
await recalcularBancoHorasPeriodo(ctx, funcionarioId, dataInicio, dataFim);
// Atualizar status do funcionário imediatamente // Atualizar status do funcionário imediatamente
await ctx.runMutation(internal.ferias.atualizarStatusFuncionario, { await ctx.runMutation(internal.ferias.atualizarStatusFuncionario, {
funcionarioId: atestado.funcionarioId funcionarioId
}); });
return null; return null;
@@ -1200,6 +1224,13 @@ export const excluirLicenca = mutation({
const licenca = await ctx.db.get(args.id); const licenca = await ctx.db.get(args.id);
if (!licenca) throw new Error('Licença não encontrada'); if (!licenca) throw new Error('Licença não encontrada');
// IMPORTANTE: Salvar o período exato da licença ANTES de excluir
// para recalcular o banco de horas apenas para esse período específico
const funcionarioId = licenca.funcionarioId;
const dataInicio = licenca.dataInicio; // Data início da licença
const dataFim = licenca.dataFim; // Data fim da licença
// Excluir o registro do banco de dados
await ctx.db.delete(args.id); await ctx.db.delete(args.id);
await registrarAtividade( await registrarAtividade(
@@ -1211,9 +1242,13 @@ export const excluirLicenca = mutation({
args.id args.id
); );
// Recalcular banco de horas APENAS para o período específico da licença excluída
// Isso garante que os dias da licença sejam removidos corretamente dos registros de ponto
await recalcularBancoHorasPeriodo(ctx, funcionarioId, dataInicio, dataFim);
// Atualizar status do funcionário imediatamente // Atualizar status do funcionário imediatamente
await ctx.runMutation(internal.ferias.atualizarStatusFuncionario, { await ctx.runMutation(internal.ferias.atualizarStatusFuncionario, {
funcionarioId: licenca.funcionarioId funcionarioId
}); });
return null; return null;

View File

@@ -281,6 +281,59 @@ export const enviarMensagem = mutation({
}); });
} }
// Validar tamanho da mensagem
const MAX_MENSAGEM_LENGTH = 5000;
if (args.conteudo.length > MAX_MENSAGEM_LENGTH) {
throw new Error(`Mensagem muito longa. O limite é de ${MAX_MENSAGEM_LENGTH} caracteres.`);
}
// Validação de tipo de arquivo (se houver arquivo)
if (args.arquivoTipo) {
const TIPOS_PERMITIDOS = [
// Imagens
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'image/svg+xml',
// Documentos
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'text/plain',
'text/csv',
// Arquivos
'application/zip',
'application/x-rar-compressed',
'application/x-7z-compressed',
'application/x-tar',
'application/gzip'
];
if (!TIPOS_PERMITIDOS.includes(args.arquivoTipo)) {
throw new Error('Tipo de arquivo não permitido');
}
// Validar tamanho de arquivo (10MB)
const MAX_FILE_SIZE = 10 * 1024 * 1024;
if (args.arquivoTamanho && args.arquivoTamanho > MAX_FILE_SIZE) {
throw new Error(`Arquivo muito grande. O tamanho máximo é ${MAX_FILE_SIZE / 1024 / 1024}MB.`);
}
// Validar nome do arquivo (sanitizar)
if (args.arquivoNome) {
const nomeSanitizado = args.arquivoNome.replace(/[^a-zA-Z0-9._-]/g, '_');
if (nomeSanitizado !== args.arquivoNome) {
// Se o nome foi alterado, usar o sanitizado
args.arquivoNome = nomeSanitizado;
}
}
}
// Verificar se usuário pertence à conversa // Verificar se usuário pertence à conversa
const conversa = await ctx.db.get(args.conversaId); const conversa = await ctx.db.get(args.conversaId);
if (!conversa) throw new Error('Conversa não encontrada'); if (!conversa) throw new Error('Conversa não encontrada');
@@ -1716,28 +1769,42 @@ export const listarConversas = query({
export const obterMensagens = query({ export const obterMensagens = query({
args: { args: {
conversaId: v.id('conversas'), conversaId: v.id('conversas'),
limit: v.optional(v.number()) limit: v.optional(v.number()),
cursor: v.optional(v.id('mensagens')) // ID da última mensagem carregada (para paginação)
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const usuarioAtual = await getUsuarioAutenticado(ctx); const usuarioAtual = await getUsuarioAutenticado(ctx);
if (!usuarioAtual) return []; if (!usuarioAtual) return { mensagens: [], hasMore: false };
// Verificar se usuário pertence à conversa (SEGURANÇA CRÍTICA) // Verificar se usuário pertence à conversa (SEGURANÇA CRÍTICA)
const conversa = await ctx.db.get(args.conversaId); const conversa = await ctx.db.get(args.conversaId);
if (!conversa || !conversa.participantes.includes(usuarioAtual._id)) { if (!conversa || !conversa.participantes.includes(usuarioAtual._id)) {
return []; return { mensagens: [], hasMore: false };
} }
// Buscar mensagens (excluir agendadas) const limit = args.limit || 50;
const mensagens = await ctx.db let query = ctx.db
.query('mensagens') .query('mensagens')
.withIndex('by_conversa', (q) => q.eq('conversaId', args.conversaId)) .withIndex('by_conversa', (q) => q.eq('conversaId', args.conversaId))
.order('desc') .order('desc');
.take(args.limit || 50);
// Se há cursor, buscar mensagens anteriores a ele
if (args.cursor) {
const cursorMsg = await ctx.db.get(args.cursor);
if (cursorMsg && cursorMsg.conversaId === args.conversaId) {
// Buscar mensagens anteriores à mensagem do cursor
query = query.filter((q) => q.lt(q.field('_creationTime'), cursorMsg._creationTime));
}
}
// Buscar uma mensagem a mais para verificar se há mais mensagens
const mensagens = await query.take(limit + 1);
const hasMore = mensagens.length > limit;
const mensagensParaRetornar = hasMore ? mensagens.slice(0, limit) : mensagens;
// Filtrar mensagens agendadas e garantir que são da conversa correta // Filtrar mensagens agendadas e garantir que são da conversa correta
// SEGURANÇA: Apenas mensagens de participantes da conversa são retornadas // SEGURANÇA: Apenas mensagens de participantes da conversa são retornadas
const mensagensFiltradas = mensagens.filter((m) => { const mensagensFiltradas = mensagensParaRetornar.filter((m) => {
// Excluir agendadas // Excluir agendadas
if (m.agendadaPara) return false; if (m.agendadaPara) return false;
@@ -1751,7 +1818,7 @@ export const obterMensagens = query({
// Enriquecer com informações do remetente e mensagem respondida // Enriquecer com informações do remetente e mensagem respondida
const mensagensEnriquecidas = await Promise.all( const mensagensEnriquecidas = await Promise.all(
mensagensFiltradas.map(async (mensagem) => { mensagensParaRetornar.map(async (mensagem) => {
const remetente = await ctx.db.get(mensagem.remetenteId); const remetente = await ctx.db.get(mensagem.remetenteId);
// SEGURANÇA: Não retornar informações de remetente se não for participante // SEGURANÇA: Não retornar informações de remetente se não for participante
@@ -1794,7 +1861,15 @@ export const obterMensagens = query({
); );
// Filtrar nulls (caso alguma mensagem tenha sido rejeitada por segurança) // Filtrar nulls (caso alguma mensagem tenha sido rejeitada por segurança)
return mensagensEnriquecidas.filter((m) => m !== null).reverse(); const mensagensFinais = mensagensEnriquecidas.filter((m) => m !== null).reverse();
return {
mensagens: mensagensFinais,
hasMore,
nextCursor: hasMore && mensagensFinais.length > 0
? mensagensFinais[mensagensFinais.length - 1]._id
: null
};
} }
}); });

View File

@@ -941,18 +941,22 @@ export const atualizarStatusFuncionario = internalMutation({
console.log(`[atualizarStatusFuncionario] Funcionário ${func._id} está em férias`); console.log(`[atualizarStatusFuncionario] Funcionário ${func._id} está em férias`);
} else { } else {
// Se não está em férias, verificar se está em licença // Se não está em férias, verificar se está em licença
console.log(
`[atualizarStatusFuncionario] Verificando licença ativa para funcionário ${func._id}, data: ${hoje.toISOString()}`
);
const emLicenca = await verificarLicencaAtiva(ctx, func._id, hoje); const emLicenca = await verificarLicencaAtiva(ctx, func._id, hoje);
novoStatus = emLicenca ? 'em_licenca' : 'ativo'; novoStatus = emLicenca ? 'em_licenca' : 'ativo';
console.log( console.log(
`[atualizarStatusFuncionario] Funcionário ${func._id}: emLicenca=${emLicenca}, novoStatus=${novoStatus}` `[atualizarStatusFuncionario] Funcionário ${func._id}: emLicenca=${emLicenca}, statusAtual=${func.statusFerias}, novoStatus=${novoStatus}`
); );
} }
if (func.statusFerias !== novoStatus) { if (func.statusFerias !== novoStatus) {
console.log( console.log(
`[atualizarStatusFuncionario] Atualizando status de ${func.statusFerias} para ${novoStatus}` `[atualizarStatusFuncionario] ⚠️ ATUALIZANDO status de "${func.statusFerias}" para "${novoStatus}"`
); );
await ctx.db.patch(func._id, { statusFerias: novoStatus }); await ctx.db.patch(func._id, { statusFerias: novoStatus });
console.log(`[atualizarStatusFuncionario] ✅ Status atualizado com sucesso!`);
} else { } else {
console.log(`[atualizarStatusFuncionario] Status já está correto: ${novoStatus}`); console.log(`[atualizarStatusFuncionario] Status já está correto: ${novoStatus}`);
} }

View File

@@ -612,49 +612,62 @@ export const getStatusSistema = query({
ultimaAtualizacao: v.number() ultimaAtualizacao: v.number()
}), }),
handler: async (ctx) => { handler: async (ctx) => {
// Última métrica, se existir try {
const ultimaMetrica = (await ctx.db.query('systemMetrics').order('desc').first()) ?? null; // Últimatrica, se existir
const ultimaMetrica = (await ctx.db.query('systemMetrics').order('desc').first()) ?? null;
// Usuários online: usar métrica se disponível, senão derivar de usuários // Usuários online: usar métrica se disponível, senão derivar de usuários
let usuariosOnline = 0; let usuariosOnline = 0;
if (ultimaMetrica?.usuariosOnline !== undefined) { if (ultimaMetrica?.usuariosOnline !== undefined) {
usuariosOnline = ultimaMetrica.usuariosOnline; usuariosOnline = ultimaMetrica.usuariosOnline;
} else { } else {
const usuarios = await ctx.db.query('usuarios').collect(); const usuarios = await ctx.db.query('usuarios').collect();
usuariosOnline = usuarios.filter((u) => u.statusPresenca === 'online').length; usuariosOnline = usuarios.filter((u) => u.statusPresenca === 'online').length;
}
// Total de registros (estimativa baseada em tabelas principais)
const [usuarios, funcionarios, simbolos, alertas, metricas] = await Promise.all([
ctx.db.query('usuarios').collect(),
ctx.db.query('funcionarios').collect(),
ctx.db.query('simbolos').collect(),
ctx.db.query('alertConfigurations').collect(),
ctx.db.query('systemMetrics').take(100) // não precisa contar tudo
]);
const totalRegistros =
usuarios.length + funcionarios.length + simbolos.length + alertas.length + metricas.length;
// Métricas de performance com fallbacks seguros
const tempoMedioResposta = ultimaMetrica?.tempoRespostaMedio ?? 0;
const cpuUsada = Math.max(
0,
Math.min(100, Math.round((ultimaMetrica?.cpuUsage ?? 0) * 100) / 100)
);
const memoriaUsada = Math.max(
0,
Math.min(100, Math.round((ultimaMetrica?.memoryUsage ?? 0) * 100) / 100)
);
const ultimaAtualizacao = ultimaMetrica?.timestamp ?? Date.now();
return {
usuariosOnline,
totalRegistros,
tempoMedioResposta,
cpuUsada,
memoriaUsada,
ultimaAtualizacao
};
} catch (error) {
console.error('Erro em getStatusSistema:', error);
// Retornar valores padrão em caso de erro
return {
usuariosOnline: 0,
totalRegistros: 0,
tempoMedioResposta: 0,
cpuUsada: 0,
memoriaUsada: 0,
ultimaAtualizacao: Date.now()
};
} }
// Total de registros (estimativa baseada em tabelas principais)
const [usuarios, funcionarios, simbolos, alertas, metricas] = await Promise.all([
ctx.db.query('usuarios').collect(),
ctx.db.query('funcionarios').collect(),
ctx.db.query('simbolos').collect(),
ctx.db.query('alertConfigurations').collect(),
ctx.db.query('systemMetrics').take(100) // não precisa contar tudo
]);
const totalRegistros =
usuarios.length + funcionarios.length + simbolos.length + alertas.length + metricas.length;
// Métricas de performance com fallbacks seguros
const tempoMedioResposta = ultimaMetrica?.tempoRespostaMedio ?? 0;
const cpuUsada = Math.max(
0,
Math.min(100, Math.round((ultimaMetrica?.cpuUsage ?? 0) * 100) / 100)
);
const memoriaUsada = Math.max(
0,
Math.min(100, Math.round((ultimaMetrica?.memoryUsage ?? 0) * 100) / 100)
);
const ultimaAtualizacao = ultimaMetrica?.timestamp ?? Date.now();
return {
usuariosOnline,
totalRegistros,
tempoMedioResposta,
cpuUsada,
memoriaUsada,
ultimaAtualizacao
};
} }
}); });
@@ -673,22 +686,23 @@ export const getAtividadeBancoDados = query({
) )
}), }),
handler: async (ctx) => { handler: async (ctx) => {
const agora = Date.now(); try {
const haUmMinuto = agora - 60 * 1000; const agora = Date.now();
const haUmMinuto = agora - 60 * 1000;
// Buscar atividades reais do sistema // Buscar atividades reais do sistema
const atividadesRecentes = await ctx.db const atividadesRecentes = await ctx.db
.query('logsAtividades') .query('logsAtividades')
.withIndex('by_timestamp', (q) => q.gte('timestamp', haUmMinuto)) .withIndex('by_timestamp', (q) => q.gte('timestamp', haUmMinuto))
.order('asc') .order('asc')
.collect(); .collect();
// Buscar métricas também (para mensagens se houver) // Buscar métricas também (para mensagens se houver)
const metricasRecentes = await ctx.db const metricasRecentes = await ctx.db
.query('systemMetrics') .query('systemMetrics')
.withIndex('by_timestamp', (q) => q.gte('timestamp', haUmMinuto)) .withIndex('by_timestamp', (q) => q.gte('timestamp', haUmMinuto))
.order('asc') .order('asc')
.collect(); .collect();
// Bucketizar em 30 pontos (~2s cada) para visualização // Bucketizar em 30 pontos (~2s cada) para visualização
const numBuckets = 30; const numBuckets = 30;
@@ -727,6 +741,11 @@ export const getAtividadeBancoDados = query({
} }
return { historico }; return { historico };
} catch (error) {
console.error('Erro em getAtividadeBancoDados:', error);
// Retornar histórico vazio em caso de erro
return { historico: Array(30).fill({ entradas: 0, saidas: 0 }) };
}
} }
}); });
@@ -742,20 +761,21 @@ export const getDistribuicaoRequisicoes = query({
escritas: v.number() escritas: v.number()
}), }),
handler: async (ctx) => { handler: async (ctx) => {
const umaHoraAtras = Date.now() - 60 * 60 * 1000; try {
const umaHoraAtras = Date.now() - 60 * 60 * 1000;
// Buscar atividades reais do sistema // Buscar atividades reais do sistema
const atividades = await ctx.db const atividades = await ctx.db
.query('logsAtividades') .query('logsAtividades')
.withIndex('by_timestamp', (q) => q.gte('timestamp', umaHoraAtras)) .withIndex('by_timestamp', (q) => q.gte('timestamp', umaHoraAtras))
.collect(); .collect();
// Buscar métricas também // Buscar métricas também
const metricas = await ctx.db const metricas = await ctx.db
.query('systemMetrics') .query('systemMetrics')
.withIndex('by_timestamp', (q) => q.gte('timestamp', umaHoraAtras)) .withIndex('by_timestamp', (q) => q.gte('timestamp', umaHoraAtras))
.order('desc') .order('desc')
.take(100); .take(100);
// Contar operações de leitura (consultas, visualizações) // Contar operações de leitura (consultas, visualizações)
const leituras = atividades.filter( const leituras = atividades.filter(
@@ -792,5 +812,10 @@ export const getDistribuicaoRequisicoes = query({
const mutations = escritas + Math.round(totalMensagens * 0.3); const mutations = escritas + Math.round(totalMensagens * 0.3);
return { queries, mutations, leituras, escritas }; return { queries, mutations, leituras, escritas };
} catch (error) {
console.error('Erro em getDistribuicaoRequisicoes:', error);
// Retornar valores padrão em caso de erro
return { queries: 0, mutations: 0, leituras: 0, escritas: 0 };
}
} }
}); });