feat: enhance chat components with improved accessibility features, including ARIA attributes for search and user status, and implement message length validation and file type checks in message input handling

This commit is contained in:
2025-12-08 23:16:05 -03:00
parent e46738c5bf
commit 1810cbabe2
22 changed files with 1364 additions and 249 deletions

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 };
}
} }
}); });