Ajustes gerais #57
@@ -188,7 +188,10 @@
|
||||
placeholder="Buscar usuários (nome, email, matrícula)..."
|
||||
class="input input-bordered w-full pl-10"
|
||||
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
|
||||
class="text-base-content/50 absolute top-1/2 left-3 h-5 w-5 -translate-y-1/2"
|
||||
strokeWidth={1.5}
|
||||
@@ -244,6 +247,8 @@
|
||||
: 'cursor-pointer'}"
|
||||
onclick={() => handleClickUsuario(usuario)}
|
||||
disabled={processando}
|
||||
aria-label="Abrir conversa com {usuario.nome}"
|
||||
aria-describedby="usuario-status-{usuario._id}"
|
||||
>
|
||||
<!-- Ícone de mensagem -->
|
||||
<div
|
||||
@@ -260,6 +265,7 @@
|
||||
fotoPerfilUrl={usuario.fotoPerfilUrl}
|
||||
nome={usuario.nome}
|
||||
size="md"
|
||||
userId={usuario._id}
|
||||
/>
|
||||
<!-- Status badge -->
|
||||
<div class="absolute right-0 bottom-0">
|
||||
@@ -290,6 +296,9 @@
|
||||
{usuario.statusMensagem || usuario.email}
|
||||
</p>
|
||||
</div>
|
||||
<span id="usuario-status-{usuario._id}" class="sr-only">
|
||||
Status: {getStatusLabel(usuario.statusPresenca)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
@@ -7,35 +7,52 @@
|
||||
minimizarChat,
|
||||
maximizarChat,
|
||||
abrirChat,
|
||||
abrirConversa
|
||||
abrirConversa,
|
||||
notificacaoAtiva
|
||||
} from '$lib/stores/chatStore';
|
||||
import { useQuery } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import ChatList from './ChatList.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';
|
||||
|
||||
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, {});
|
||||
// Usuário atual
|
||||
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 isMinimized = $derived(false);
|
||||
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(() => {
|
||||
// Priorizar perfil (tem mais informações)
|
||||
const perfil = meuPerfilQuery?.data;
|
||||
if (perfil?.fotoPerfilUrl) {
|
||||
return perfil.fotoPerfilUrl;
|
||||
}
|
||||
|
||||
// Fallback para currentUser
|
||||
const usuario = currentUser?.data;
|
||||
if (!usuario) return null;
|
||||
|
||||
// Prioridade: fotoPerfilUrl > avatar > fallback com nome
|
||||
if (usuario.fotoPerfilUrl) {
|
||||
if (usuario?.fotoPerfilUrl) {
|
||||
return usuario.fotoPerfilUrl;
|
||||
}
|
||||
|
||||
@@ -52,6 +69,7 @@
|
||||
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 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)
|
||||
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(() => {
|
||||
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 meuIdAtual = meuId();
|
||||
|
||||
// Encontrar conversas com novas mensagens
|
||||
// Obter ID do usuário logado de forma robusta
|
||||
// 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
|
||||
});
|
||||
if (!meuIdAtual) {
|
||||
console.warn('⚠️ [ChatWidget] Não foi possível identificar o ID do usuário logado');
|
||||
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) => {
|
||||
if (!conv.ultimaMensagemTimestamp) return;
|
||||
|
||||
@@ -455,21 +455,8 @@
|
||||
? String(conv.ultimaMensagemRemetenteId).trim()
|
||||
: 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
|
||||
if (remetenteIdStr && remetenteIdStr === meuId) {
|
||||
if (remetenteIdStr && remetenteIdStr === meuIdAtual) {
|
||||
// Mensagem enviada pelo próprio usuário - não tocar beep nem mostrar notificação
|
||||
// Marcar como notificada para evitar processamento futuro
|
||||
const mensagemId = `${conv._id}-${conv.ultimaMensagemTimestamp}`;
|
||||
@@ -490,14 +477,29 @@
|
||||
const conversaIdStr = String(conv._id).trim();
|
||||
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:
|
||||
// 1. O chat não está aberto OU
|
||||
// 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)
|
||||
mensagensNotificadasGlobal.add(mensagemId);
|
||||
salvarMensagensNotificadasGlobal();
|
||||
|
||||
// Registrar notificação ativa no store global
|
||||
notificacaoAtiva.set({
|
||||
conversaId: conversaIdStr,
|
||||
mensagemId,
|
||||
componente: 'widget'
|
||||
});
|
||||
|
||||
// Tocar som de notificação (apenas uma vez)
|
||||
tocarSomNotificacaoGlobal();
|
||||
|
||||
@@ -509,13 +511,17 @@
|
||||
};
|
||||
showGlobalNotificationPopup = true;
|
||||
|
||||
// Ocultar popup após 5 segundos
|
||||
// Ocultar popup após 5 segundos - garantir limpeza
|
||||
if (globalNotificationTimeout) {
|
||||
clearTimeout(globalNotificationTimeout);
|
||||
globalNotificationTimeout = null;
|
||||
}
|
||||
globalNotificationTimeout = setTimeout(() => {
|
||||
showGlobalNotificationPopup = false;
|
||||
globalNotificationMessage = null;
|
||||
globalNotificationTimeout = null;
|
||||
// Limpar notificação ativa do store
|
||||
notificacaoAtiva.set(null);
|
||||
}, 5000);
|
||||
} else {
|
||||
// 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() {
|
||||
@@ -583,6 +597,56 @@
|
||||
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
|
||||
function handleMouseDown(e: MouseEvent) {
|
||||
if (e.button !== 0 || !position) return; // Apenas botão esquerdo
|
||||
@@ -929,6 +993,12 @@
|
||||
}}
|
||||
ontouchstart={handleTouchStart}
|
||||
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
|
||||
if (!shouldPreventClick && !hasMoved && !isTouching) {
|
||||
handleToggle();
|
||||
@@ -939,7 +1009,16 @@
|
||||
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 -->
|
||||
<div
|
||||
@@ -948,8 +1027,21 @@
|
||||
></div>
|
||||
<!-- Segunda camada para efeito de profundidade -->
|
||||
<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;"
|
||||
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>
|
||||
<!-- Efeito de brilho pulsante durante arrasto -->
|
||||
{#if isDragging || isTouching}
|
||||
@@ -960,8 +1052,8 @@
|
||||
{/if}
|
||||
|
||||
<!-- Ícone de chat moderno com efeito 3D -->
|
||||
<MessageCircle
|
||||
class="relative z-10 h-7 w-7 text-white transition-all duration-500 group-hover:scale-110"
|
||||
<MessageSquare
|
||||
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));"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
@@ -1057,7 +1149,7 @@
|
||||
{#if avatarUrlDoUsuario()}
|
||||
<img
|
||||
src={avatarUrlDoUsuario()}
|
||||
alt={currentUser?.data?.nome || 'Usuário'}
|
||||
alt={meuPerfilQuery?.data?.nome || currentUser?.data?.nome || 'Usuário'}
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
@@ -1223,6 +1315,9 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Indicador de Conexão -->
|
||||
<ConnectionIndicator />
|
||||
|
||||
<!-- Popup Global de Notificação de Nova Mensagem (quando chat está fechado/minimizado) -->
|
||||
{#if showGlobalNotificationPopup && globalNotificationMessage}
|
||||
{@const notificationMsg = globalNotificationMessage}
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
XCircle,
|
||||
Phone,
|
||||
Video,
|
||||
ChevronDown
|
||||
ChevronDown,
|
||||
Search
|
||||
} from 'lucide-svelte';
|
||||
|
||||
//import { Bell, X, ArrowLeft, LogOut, MoreVertical, Users, Clock, XCircle } from 'lucide-svelte';
|
||||
@@ -46,6 +47,11 @@
|
||||
let showNotificacaoModal = $state(false);
|
||||
let iniciandoChamada = $state(false);
|
||||
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
|
||||
let showErrorModal = $state(false);
|
||||
@@ -276,6 +282,7 @@
|
||||
fotoPerfilUrl={conversa()?.outroUsuario?.fotoPerfilUrl}
|
||||
nome={conversa()?.outroUsuario?.nome || 'Usuário'}
|
||||
size="md"
|
||||
userId={conversa()?.outroUsuario?._id}
|
||||
/>
|
||||
{:else}
|
||||
<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 -->
|
||||
<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 -->
|
||||
{#if !chamadaAtual && !chamadaAtiva}
|
||||
<div class="dropdown dropdown-end">
|
||||
@@ -596,6 +629,110 @@
|
||||
</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 -->
|
||||
<div class="min-h-0 flex-1 overflow-hidden">
|
||||
<MessageList conversaId={conversaId as Id<'conversas'>} />
|
||||
|
||||
90
apps/web/src/lib/components/chat/ConnectionIndicator.svelte
Normal file
90
apps/web/src/lib/components/chat/ConnectionIndicator.svelte
Normal 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}
|
||||
|
||||
@@ -28,10 +28,42 @@
|
||||
const client = useConvexClient();
|
||||
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 textarea: HTMLTextAreaElement;
|
||||
let enviando = $state(false);
|
||||
let uploadingFile = $state(false);
|
||||
let uploadProgress = $state(0); // Progresso do upload (0-100)
|
||||
let digitacaoTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let showEmojiPicker = $state(false);
|
||||
let mensagemRespondendo: {
|
||||
@@ -42,6 +74,8 @@
|
||||
let showMentionsDropdown = $state(false);
|
||||
let mentionQuery = $state('');
|
||||
let mentionStartPos = $state(0);
|
||||
let selectedMentionIndex = $state(0); // Índice do participante selecionado no dropdown
|
||||
let mensagemMuitoLonga = $state(false);
|
||||
|
||||
// Emojis mais usados
|
||||
const emojis = [
|
||||
@@ -134,6 +168,19 @@
|
||||
// Auto-resize do textarea e detectar menções
|
||||
function handleInput(e: Event) {
|
||||
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) {
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px';
|
||||
@@ -160,11 +207,13 @@
|
||||
// Indicador de digitação (debounce de 1s)
|
||||
if (digitacaoTimeout) {
|
||||
clearTimeout(digitacaoTimeout);
|
||||
digitacaoTimeout = null;
|
||||
}
|
||||
digitacaoTimeout = setTimeout(() => {
|
||||
if (mensagem.trim()) {
|
||||
client.mutation(api.chat.indicarDigitacao, { conversaId });
|
||||
}
|
||||
digitacaoTimeout = null;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
@@ -175,6 +224,7 @@
|
||||
mensagem = antes + `@${nome} ` + depois;
|
||||
showMentionsDropdown = false;
|
||||
mentionQuery = '';
|
||||
selectedMentionIndex = 0; // Resetar índice selecionado
|
||||
if (textarea) {
|
||||
textarea.focus();
|
||||
const newPos = antes.length + nome.length + 2;
|
||||
@@ -187,6 +237,12 @@
|
||||
async function handleEnviar() {
|
||||
const texto = mensagem.trim();
|
||||
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)
|
||||
const mencoesIds: Id<'usuarios'>[] = [];
|
||||
@@ -270,22 +326,43 @@
|
||||
window.addEventListener('responderMensagem', handler);
|
||||
return () => {
|
||||
window.removeEventListener('responderMensagem', handler);
|
||||
// Limpar timeout de digitação ao desmontar
|
||||
if (digitacaoTimeout) {
|
||||
clearTimeout(digitacaoTimeout);
|
||||
digitacaoTimeout = null;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
// Navegar dropdown de menções
|
||||
if (showMentionsDropdown && participantesFiltrados().length > 0) {
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter') {
|
||||
const participantes = participantesFiltrados();
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
// Implementação simples: selecionar primeiro participante
|
||||
if (e.key === 'Enter') {
|
||||
inserirMencao(participantesFiltrados()[0]);
|
||||
selectedMentionIndex = Math.min(selectedMentionIndex + 1, participantes.length - 1);
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
showMentionsDropdown = false;
|
||||
selectedMentionIndex = 0;
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -302,41 +379,111 @@
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Validar tamanho (max 10MB)
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
alert('Arquivo muito grande. O tamanho máximo é 10MB.');
|
||||
// Validar tamanho
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
alert(`Arquivo muito grande. O tamanho máximo é ${(MAX_FILE_SIZE / 1024 / 1024).toFixed(0)}MB.`);
|
||||
input.value = '';
|
||||
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 {
|
||||
uploadingFile = true;
|
||||
uploadProgress = 0;
|
||||
|
||||
// 1. Obter upload URL
|
||||
const uploadUrl = await client.mutation(api.chat.uploadArquivoChat, {
|
||||
conversaId
|
||||
});
|
||||
|
||||
// 2. Upload do arquivo
|
||||
const result = await fetch(uploadUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': file.type },
|
||||
body: file
|
||||
// 2. Upload do arquivo com progresso
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
// Promise para aguardar upload completo
|
||||
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) {
|
||||
throw new Error('Falha no upload');
|
||||
}
|
||||
|
||||
const { storageId } = await result.json();
|
||||
const storageId = await uploadPromise;
|
||||
|
||||
// 3. Enviar mensagem com o arquivo
|
||||
const tipo: 'imagem' | 'arquivo' = file.type.startsWith('image/') ? 'imagem' : 'arquivo';
|
||||
await client.mutation(api.chat.enviarMensagem, {
|
||||
conversaId,
|
||||
conteudo: tipo === 'imagem' ? '' : file.name,
|
||||
conteudo: tipo === 'imagem' ? '' : nomeSanitizado,
|
||||
tipo,
|
||||
arquivoId: storageId,
|
||||
arquivoNome: file.name,
|
||||
arquivoNome: nomeSanitizado,
|
||||
arquivoTamanho: file.size,
|
||||
arquivoTipo: file.type
|
||||
});
|
||||
@@ -383,31 +530,48 @@
|
||||
|
||||
<div class="flex items-end gap-2">
|
||||
<!-- Botão de anexar arquivo MODERNO -->
|
||||
<label
|
||||
class="group relative flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-xl transition-all duration-300"
|
||||
style="background: rgba(102, 126, 234, 0.1); border: 1px solid rgba(102, 126, 234, 0.2);"
|
||||
title="Anexar arquivo"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
class="hidden"
|
||||
onchange={handleFileUpload}
|
||||
disabled={uploadingFile || enviando}
|
||||
accept="*/*"
|
||||
/>
|
||||
<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}
|
||||
<div class="relative shrink-0">
|
||||
<label
|
||||
class="group relative flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-xl transition-all duration-300"
|
||||
style="background: rgba(102, 126, 234, 0.1); border: 1px solid rgba(102, 126, 234, 0.2);"
|
||||
title="Anexar arquivo"
|
||||
aria-label="Anexar arquivo"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
class="hidden"
|
||||
onchange={handleFileUpload}
|
||||
disabled={uploadingFile || enviando}
|
||||
accept="*/*"
|
||||
aria-label="Selecionar arquivo para anexar"
|
||||
/>
|
||||
<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}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Botão de EMOJI MODERNO -->
|
||||
<div class="relative shrink-0">
|
||||
@@ -418,6 +582,8 @@
|
||||
onclick={() => (showEmojiPicker = !showEmojiPicker)}
|
||||
disabled={enviando || uploadingFile}
|
||||
aria-label="Adicionar emoji"
|
||||
aria-expanded={showEmojiPicker}
|
||||
aria-haspopup="true"
|
||||
title="Adicionar emoji"
|
||||
>
|
||||
<div
|
||||
@@ -434,13 +600,18 @@
|
||||
<div
|
||||
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;"
|
||||
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}
|
||||
<button
|
||||
type="button"
|
||||
class="hover:bg-base-200 cursor-pointer rounded p-1 text-2xl transition-transform hover:scale-125"
|
||||
onclick={() => adicionarEmoji(emoji)}
|
||||
aria-label="Adicionar emoji {emoji}"
|
||||
role="gridcell"
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
@@ -458,21 +629,43 @@
|
||||
oninput={handleInput}
|
||||
onkeydown={handleKeyDown}
|
||||
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"
|
||||
disabled={enviando || uploadingFile}
|
||||
maxlength={MAX_MENSAGEM_LENGTH}
|
||||
aria-label="Campo de mensagem"
|
||||
aria-describedby="mensagem-help"
|
||||
aria-invalid={mensagemMuitoLonga}
|
||||
></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 -->
|
||||
{#if showMentionsDropdown && participantesFiltrados().length > 0 && (conversa()?.tipo === 'grupo' || conversa()?.tipo === 'sala_reuniao')}
|
||||
<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"
|
||||
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
|
||||
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)}
|
||||
role="option"
|
||||
aria-selected={index === selectedMentionIndex}
|
||||
aria-label="Mencionar {participante.nome}"
|
||||
id="mention-option-{index}"
|
||||
>
|
||||
<div
|
||||
class="bg-primary/20 flex h-8 w-8 items-center justify-center overflow-hidden rounded-full"
|
||||
@@ -502,14 +695,15 @@
|
||||
</div>
|
||||
|
||||
<!-- Botão de enviar MODERNO -->
|
||||
<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"
|
||||
style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); box-shadow: 0 8px 24px -4px rgba(102, 126, 234, 0.4);"
|
||||
onclick={handleEnviar}
|
||||
disabled={!mensagem.trim() || enviando || uploadingFile}
|
||||
aria-label="Enviar"
|
||||
>
|
||||
<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"
|
||||
style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); box-shadow: 0 8px 24px -4px rgba(102, 126, 234, 0.4);"
|
||||
onclick={handleEnviar}
|
||||
disabled={!mensagem.trim() || enviando || uploadingFile}
|
||||
aria-label="Enviar mensagem"
|
||||
aria-describedby="mensagem-help"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-white/0 transition-colors duration-300 group-hover:bg-white/10"
|
||||
></div>
|
||||
@@ -525,7 +719,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Informação sobre atalhos -->
|
||||
<p class="text-base-content/50 mt-2 text-center text-xs">
|
||||
💡 Enter para enviar • Shift+Enter para quebrar linha • 😊 Clique no emoji
|
||||
<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 • Use @ para mencionar
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
import { onMount, tick } from 'svelte';
|
||||
import { File, CheckCircle2, CheckCircle, MessageSquare, Bell, X } from 'lucide-svelte';
|
||||
import { notificacaoAtiva } from '$lib/stores/chatStore';
|
||||
|
||||
interface Props {
|
||||
conversaId: Id<'conversas'>;
|
||||
@@ -14,10 +15,45 @@
|
||||
let { conversaId }: Props = $props();
|
||||
|
||||
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,
|
||||
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 isAdmin = useQuery(api.chat.verificarSeEhAdmin, { conversaId });
|
||||
const conversas = useQuery(api.chat.listarConversas, {});
|
||||
@@ -54,8 +90,8 @@
|
||||
mensagensCarregadas = true;
|
||||
|
||||
// Marcar todas as mensagens atuais como já visualizadas (não tocar beep ao abrir)
|
||||
if (mensagens?.data && mensagens.data.length > 0) {
|
||||
mensagens.data.forEach((msg) => {
|
||||
if (todasMensagens.length > 0) {
|
||||
todasMensagens.forEach((msg) => {
|
||||
mensagensNotificadas.add(String(msg._id));
|
||||
});
|
||||
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
|
||||
// E detectar novas mensagens para tocar som e mostrar popup
|
||||
$effect(() => {
|
||||
if (mensagens?.data && messagesContainer) {
|
||||
const currentCount = mensagens.data.length;
|
||||
if (todasMensagens.length > 0 && messagesContainer) {
|
||||
const currentCount = todasMensagens.length;
|
||||
const isNewMessage = currentCount > lastMessageCount;
|
||||
|
||||
// Detectar nova mensagem de outro usuário
|
||||
if (isNewMessage && mensagens.data.length > 0 && usuarioAtualId) {
|
||||
const ultimaMensagem = mensagens.data[mensagens.data.length - 1];
|
||||
if (isNewMessage && todasMensagens.length > 0 && usuarioAtualId) {
|
||||
const ultimaMensagem = todasMensagens[todasMensagens.length - 1];
|
||||
const mensagemId = String(ultimaMensagem._id);
|
||||
const remetenteIdStr = ultimaMensagem.remetenteId
|
||||
? String(ultimaMensagem.remetenteId).trim()
|
||||
@@ -164,16 +228,33 @@
|
||||
? String(ultimaMensagem.remetente._id).trim()
|
||||
: 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
|
||||
// E não há outra notificação ativa para esta mensagem
|
||||
if (
|
||||
remetenteIdStr &&
|
||||
remetenteIdStr !== usuarioAtualId &&
|
||||
!mensagensNotificadas.has(mensagemId)
|
||||
!mensagensNotificadas.has(mensagemId) &&
|
||||
!jaTemNotificacaoAtiva
|
||||
) {
|
||||
// Marcar como notificada antes de tocar som (evita duplicação)
|
||||
mensagensNotificadas.add(mensagemId);
|
||||
salvarMensagensNotificadas();
|
||||
|
||||
// Registrar notificação ativa no store global
|
||||
notificacaoAtiva.set({
|
||||
conversaId: conversaIdStr,
|
||||
mensagemId,
|
||||
componente: 'messageList'
|
||||
});
|
||||
|
||||
// Tocar som de notificação (apenas uma vez)
|
||||
tocarSomNotificacao();
|
||||
|
||||
@@ -186,19 +267,55 @@
|
||||
};
|
||||
showNotificationPopup = true;
|
||||
|
||||
// Ocultar popup após 5 segundos
|
||||
// Ocultar popup após 5 segundos - garantir limpeza
|
||||
if (notificationTimeout) {
|
||||
clearTimeout(notificationTimeout);
|
||||
notificationTimeout = null;
|
||||
}
|
||||
notificationTimeout = setTimeout(() => {
|
||||
showNotificationPopup = false;
|
||||
notificationMessage = null;
|
||||
notificationTimeout = null;
|
||||
// Limpar notificação ativa do store
|
||||
notificacaoAtiva.set(null);
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
if (isNewMessage || shouldScrollToBottom) {
|
||||
// Usar requestAnimationFrame para garantir que o DOM foi atualizado
|
||||
// Scroll automático inteligente: só rolar se:
|
||||
// 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(() => {
|
||||
tick().then(() => {
|
||||
if (messagesContainer) {
|
||||
@@ -210,12 +327,20 @@
|
||||
|
||||
lastMessageCount = currentCount;
|
||||
}
|
||||
|
||||
// Cleanup: limpar timeout quando o effect for desmontado
|
||||
return () => {
|
||||
if (notificationTimeout) {
|
||||
clearTimeout(notificationTimeout);
|
||||
notificationTimeout = null;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Marcar como lida quando mensagens carregam
|
||||
$effect(() => {
|
||||
if (mensagens?.data && mensagens.data.length > 0 && usuarioAtualId) {
|
||||
const ultimaMensagem = mensagens.data[mensagens.data.length - 1];
|
||||
if (todasMensagens.length > 0 && usuarioAtualId) {
|
||||
const ultimaMensagem = todasMensagens[todasMensagens.length - 1];
|
||||
const remetenteIdStr = ultimaMensagem.remetenteId
|
||||
? String(ultimaMensagem.remetenteId).trim()
|
||||
: ultimaMensagem.remetente?._id
|
||||
@@ -302,7 +427,9 @@
|
||||
|
||||
function handleScroll(e: Event) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -435,6 +562,32 @@
|
||||
|
||||
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>
|
||||
|
||||
<div
|
||||
@@ -442,8 +595,8 @@
|
||||
bind:this={messagesContainer}
|
||||
onscroll={handleScroll}
|
||||
>
|
||||
{#if mensagens?.data && mensagens.data.length > 0}
|
||||
{@const gruposPorDia = agruparMensagensPorDia(mensagens.data)}
|
||||
{#if todasMensagens.length > 0}
|
||||
{@const gruposPorDia = agruparMensagensPorDia(todasMensagens)}
|
||||
{#each Object.entries(gruposPorDia) as [dia, mensagensDia]}
|
||||
<!-- Separador de dia -->
|
||||
<div class="my-4 flex items-center justify-center">
|
||||
@@ -512,12 +665,25 @@
|
||||
cancelarEdicao();
|
||||
}
|
||||
}}
|
||||
aria-label="Editar mensagem"
|
||||
aria-describedby="edicao-help"
|
||||
></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">
|
||||
<button class="btn btn-xs btn-ghost" onclick={cancelarEdicao}>
|
||||
<button
|
||||
class="btn btn-xs btn-ghost"
|
||||
onclick={cancelarEdicao}
|
||||
aria-label="Cancelar edição"
|
||||
>
|
||||
Cancelar
|
||||
</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>
|
||||
{:else if mensagem.deletada}
|
||||
@@ -625,6 +791,7 @@
|
||||
class="text-base-content/50 hover:text-primary mt-1 text-xs transition-colors"
|
||||
onclick={() => responderMensagem(mensagem)}
|
||||
title="Responder"
|
||||
aria-label="Responder à mensagem de {mensagem.remetente?.nome || 'usuário'}"
|
||||
>
|
||||
↪️ Responder
|
||||
</button>
|
||||
@@ -663,6 +830,7 @@
|
||||
class="text-base-content/50 hover:text-primary text-xs transition-colors"
|
||||
onclick={() => editarMensagem(mensagem)}
|
||||
title="Editar mensagem"
|
||||
aria-label="Editar esta mensagem"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
@@ -670,6 +838,7 @@
|
||||
class="text-base-content/50 hover:text-error text-xs transition-colors"
|
||||
onclick={() => deletarMensagem(mensagem._id, false)}
|
||||
title="Deletar mensagem"
|
||||
aria-label="Deletar esta mensagem"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
@@ -679,6 +848,7 @@
|
||||
class="text-base-content/50 hover:text-error text-xs transition-colors"
|
||||
onclick={() => deletarMensagem(mensagem._id, true)}
|
||||
title="Deletar mensagem (como administrador)"
|
||||
aria-label="Deletar esta mensagem como administrador"
|
||||
>
|
||||
🗑️ Admin
|
||||
</button>
|
||||
@@ -711,7 +881,22 @@
|
||||
</p>
|
||||
</div>
|
||||
{/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 -->
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
|
||||
@@ -82,19 +82,25 @@
|
||||
});
|
||||
notificacoesAusencias = notifsAusencias || [];
|
||||
} 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 =
|
||||
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);
|
||||
}
|
||||
notificacoesAusencias = [];
|
||||
}
|
||||
}
|
||||
} 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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,47 @@
|
||||
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let inactivityTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
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
|
||||
function handleActivity() {
|
||||
@@ -33,7 +74,7 @@
|
||||
inactivityTimeout = setTimeout(
|
||||
() => {
|
||||
if (usuarioAutenticado) {
|
||||
client.mutation(api.chat.atualizarStatusPresenca, { status: 'ausente' });
|
||||
atualizarStatusPresencaSeguro('ausente');
|
||||
}
|
||||
},
|
||||
5 * 60 * 1000
|
||||
@@ -45,7 +86,7 @@
|
||||
if (!usuarioAutenticado) return;
|
||||
|
||||
// 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)
|
||||
heartbeatInterval = setInterval(() => {
|
||||
@@ -61,7 +102,7 @@
|
||||
|
||||
// Se houve atividade nos últimos 5 minutos, manter online
|
||||
if (timeSinceLastActivity < 5 * 60 * 1000) {
|
||||
client.mutation(api.chat.atualizarStatusPresenca, { status: 'online' });
|
||||
atualizarStatusPresencaSeguro('online');
|
||||
}
|
||||
}, 30 * 1000);
|
||||
|
||||
@@ -82,10 +123,10 @@
|
||||
|
||||
if (document.hidden) {
|
||||
// Aba ficou inativa
|
||||
client.mutation(api.chat.atualizarStatusPresenca, { status: 'ausente' });
|
||||
atualizarStatusPresencaSeguro('ausente');
|
||||
} else {
|
||||
// Aba ficou ativa
|
||||
client.mutation(api.chat.atualizarStatusPresenca, { status: 'online' });
|
||||
atualizarStatusPresencaSeguro('online');
|
||||
handleActivity();
|
||||
}
|
||||
}
|
||||
@@ -94,9 +135,15 @@
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
// Limpar atualização pendente
|
||||
if (pendingStatusUpdate) {
|
||||
clearTimeout(pendingStatusUpdate);
|
||||
pendingStatusUpdate = null;
|
||||
}
|
||||
|
||||
// Marcar como offline ao desmontar (apenas se autenticado)
|
||||
if (usuarioAutenticado) {
|
||||
client.mutation(api.chat.atualizarStatusPresenca, { status: 'offline' });
|
||||
atualizarStatusPresencaSeguro('offline');
|
||||
}
|
||||
|
||||
if (heartbeatInterval) {
|
||||
|
||||
@@ -1,13 +1,55 @@
|
||||
<script lang="ts">
|
||||
import { User } from 'lucide-svelte';
|
||||
import { getCachedAvatar } from '$lib/utils/avatarCache';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
fotoPerfilUrl?: string | null;
|
||||
nome: string;
|
||||
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 = {
|
||||
xs: 'w-8 h-8',
|
||||
@@ -30,11 +72,25 @@
|
||||
<div
|
||||
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
|
||||
src={fotoPerfilUrl}
|
||||
alt={`Foto de perfil de ${nome}`}
|
||||
class="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<User size={iconSizes[size]} />
|
||||
|
||||
@@ -308,7 +308,7 @@
|
||||
async function measureLatency(): Promise<number> {
|
||||
const start = performance.now();
|
||||
try {
|
||||
await fetch(window.location.origin + '/favicon.ico', {
|
||||
await fetch(window.location.origin + '/favicon.png', {
|
||||
method: 'HEAD',
|
||||
cache: 'no-cache'
|
||||
});
|
||||
|
||||
@@ -11,6 +11,14 @@ export const chatMinimizado = writable<boolean>(false);
|
||||
// Store para o contador de notificações
|
||||
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
|
||||
export function abrirChat() {
|
||||
chatAberto.set(true);
|
||||
|
||||
142
apps/web/src/lib/utils/avatarCache.ts
Normal file
142
apps/web/src/lib/utils/avatarCache.ts
Normal 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);
|
||||
}
|
||||
|
||||
@@ -434,3 +434,5 @@ export function adicionarRodape(doc: jsPDF): void {
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ async function measureNetworkLatency(): Promise<number> {
|
||||
const start = performance.now();
|
||||
|
||||
// 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',
|
||||
cache: 'no-cache'
|
||||
});
|
||||
|
||||
@@ -97,6 +97,11 @@ export async function gerarPDFComSelecao(
|
||||
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
|
||||
yPosition = adicionarResumoPeriodo(doc, yPosition, resumo, formatarHoras, formatarMinutos);
|
||||
|
||||
@@ -106,11 +111,6 @@ export async function gerarPDFComSelecao(
|
||||
// Legenda
|
||||
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
|
||||
if (sections.bancoHoras) {
|
||||
yPosition = await gerarSecaoBancoHorasPDF(
|
||||
|
||||
@@ -73,3 +73,5 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -132,6 +132,12 @@
|
||||
const funcionarios = $derived(funcionariosQuery?.data || []);
|
||||
const registros = $derived(registrosQuery?.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);
|
||||
|
||||
// Debug: Log dos dados recebidos
|
||||
@@ -585,12 +591,7 @@
|
||||
// Funções de formatação importadas de $lib/utils/ponto/formatacao
|
||||
|
||||
// Usar função centralizada formatarDataDDMMAAAA da lib/utils/ponto.ts
|
||||
|
||||
// Obter nome do funcionário selecionado
|
||||
const funcionarioSelecionadoNome = $derived.by(() => {
|
||||
if (!funcionarioIdFiltro) return null;
|
||||
return funcionarios.find((f) => f._id === funcionarioIdFiltro)?.nome || null;
|
||||
});
|
||||
// funcionarioSelecionadoNome movido para cima, logo após a definição de funcionarios
|
||||
|
||||
// Funções de cálculo importadas de $lib/utils/ponto/calculos
|
||||
// calcularSaldosParciais, calcularSaldoDiario, calcularSaldosPorPar, calcularSaldoComparativoPorPar
|
||||
|
||||
@@ -73,3 +73,5 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -69,8 +69,9 @@ export async function verificarLicencaAtiva(
|
||||
dataAtual?: Date
|
||||
): Promise<boolean> {
|
||||
// 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 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(
|
||||
`[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);
|
||||
|
||||
// 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, {
|
||||
funcionarioId: args.funcionarioId
|
||||
});
|
||||
console.log(`[criarDeclaracaoComparecimento] Status atualizado com sucesso`);
|
||||
|
||||
return atestadoId;
|
||||
}
|
||||
@@ -1027,9 +1032,13 @@ export const criarLicencaMaternidade = mutation({
|
||||
await recalcularBancoHorasPeriodo(ctx, args.funcionarioId, args.dataInicio, args.dataFim);
|
||||
|
||||
// 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, {
|
||||
funcionarioId: args.funcionarioId
|
||||
});
|
||||
console.log(`[criarLicencaMaternidade] Status atualizado com sucesso`);
|
||||
|
||||
return licencaId;
|
||||
}
|
||||
@@ -1081,9 +1090,13 @@ export const criarLicencaPaternidade = mutation({
|
||||
await recalcularBancoHorasPeriodo(ctx, args.funcionarioId, args.dataInicio, args.dataFim);
|
||||
|
||||
// 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, {
|
||||
funcionarioId: args.funcionarioId
|
||||
});
|
||||
console.log(`[criarLicencaPaternidade] Status atualizado com sucesso`);
|
||||
|
||||
return licencaId;
|
||||
}
|
||||
@@ -1165,6 +1178,13 @@ export const excluirAtestado = mutation({
|
||||
const atestado = await ctx.db.get(args.id);
|
||||
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 registrarAtividade(
|
||||
@@ -1176,9 +1196,13 @@ export const excluirAtestado = mutation({
|
||||
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
|
||||
await ctx.runMutation(internal.ferias.atualizarStatusFuncionario, {
|
||||
funcionarioId: atestado.funcionarioId
|
||||
funcionarioId
|
||||
});
|
||||
|
||||
return null;
|
||||
@@ -1200,6 +1224,13 @@ export const excluirLicenca = mutation({
|
||||
const licenca = await ctx.db.get(args.id);
|
||||
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 registrarAtividade(
|
||||
@@ -1211,9 +1242,13 @@ export const excluirLicenca = mutation({
|
||||
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
|
||||
await ctx.runMutation(internal.ferias.atualizarStatusFuncionario, {
|
||||
funcionarioId: licenca.funcionarioId
|
||||
funcionarioId
|
||||
});
|
||||
|
||||
return null;
|
||||
|
||||
@@ -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
|
||||
const conversa = await ctx.db.get(args.conversaId);
|
||||
if (!conversa) throw new Error('Conversa não encontrada');
|
||||
@@ -1716,28 +1769,42 @@ export const listarConversas = query({
|
||||
export const obterMensagens = query({
|
||||
args: {
|
||||
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) => {
|
||||
const usuarioAtual = await getUsuarioAutenticado(ctx);
|
||||
if (!usuarioAtual) return [];
|
||||
if (!usuarioAtual) return { mensagens: [], hasMore: false };
|
||||
|
||||
// Verificar se usuário pertence à conversa (SEGURANÇA CRÍTICA)
|
||||
const conversa = await ctx.db.get(args.conversaId);
|
||||
if (!conversa || !conversa.participantes.includes(usuarioAtual._id)) {
|
||||
return [];
|
||||
return { mensagens: [], hasMore: false };
|
||||
}
|
||||
|
||||
// Buscar mensagens (excluir agendadas)
|
||||
const mensagens = await ctx.db
|
||||
const limit = args.limit || 50;
|
||||
let query = ctx.db
|
||||
.query('mensagens')
|
||||
.withIndex('by_conversa', (q) => q.eq('conversaId', args.conversaId))
|
||||
.order('desc')
|
||||
.take(args.limit || 50);
|
||||
.order('desc');
|
||||
|
||||
// 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
|
||||
// SEGURANÇA: Apenas mensagens de participantes da conversa são retornadas
|
||||
const mensagensFiltradas = mensagens.filter((m) => {
|
||||
const mensagensFiltradas = mensagensParaRetornar.filter((m) => {
|
||||
// Excluir agendadas
|
||||
if (m.agendadaPara) return false;
|
||||
|
||||
@@ -1751,7 +1818,7 @@ export const obterMensagens = query({
|
||||
|
||||
// Enriquecer com informações do remetente e mensagem respondida
|
||||
const mensagensEnriquecidas = await Promise.all(
|
||||
mensagensFiltradas.map(async (mensagem) => {
|
||||
mensagensParaRetornar.map(async (mensagem) => {
|
||||
const remetente = await ctx.db.get(mensagem.remetenteId);
|
||||
|
||||
// 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)
|
||||
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
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -941,18 +941,22 @@ export const atualizarStatusFuncionario = internalMutation({
|
||||
console.log(`[atualizarStatusFuncionario] Funcionário ${func._id} está em férias`);
|
||||
} else {
|
||||
// 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);
|
||||
novoStatus = emLicenca ? 'em_licenca' : 'ativo';
|
||||
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) {
|
||||
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 });
|
||||
console.log(`[atualizarStatusFuncionario] ✅ Status atualizado com sucesso!`);
|
||||
} else {
|
||||
console.log(`[atualizarStatusFuncionario] Status já está correto: ${novoStatus}`);
|
||||
}
|
||||
|
||||
@@ -612,49 +612,62 @@ export const getStatusSistema = query({
|
||||
ultimaAtualizacao: v.number()
|
||||
}),
|
||||
handler: async (ctx) => {
|
||||
// Última métrica, se existir
|
||||
const ultimaMetrica = (await ctx.db.query('systemMetrics').order('desc').first()) ?? null;
|
||||
try {
|
||||
// Última métrica, 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
|
||||
let usuariosOnline = 0;
|
||||
if (ultimaMetrica?.usuariosOnline !== undefined) {
|
||||
usuariosOnline = ultimaMetrica.usuariosOnline;
|
||||
} else {
|
||||
const usuarios = await ctx.db.query('usuarios').collect();
|
||||
usuariosOnline = usuarios.filter((u) => u.statusPresenca === 'online').length;
|
||||
// Usuários online: usar métrica se disponível, senão derivar de usuários
|
||||
let usuariosOnline = 0;
|
||||
if (ultimaMetrica?.usuariosOnline !== undefined) {
|
||||
usuariosOnline = ultimaMetrica.usuariosOnline;
|
||||
} else {
|
||||
const usuarios = await ctx.db.query('usuarios').collect();
|
||||
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) => {
|
||||
const agora = Date.now();
|
||||
const haUmMinuto = agora - 60 * 1000;
|
||||
try {
|
||||
const agora = Date.now();
|
||||
const haUmMinuto = agora - 60 * 1000;
|
||||
|
||||
// Buscar atividades reais do sistema
|
||||
const atividadesRecentes = await ctx.db
|
||||
.query('logsAtividades')
|
||||
.withIndex('by_timestamp', (q) => q.gte('timestamp', haUmMinuto))
|
||||
.order('asc')
|
||||
.collect();
|
||||
// Buscar atividades reais do sistema
|
||||
const atividadesRecentes = await ctx.db
|
||||
.query('logsAtividades')
|
||||
.withIndex('by_timestamp', (q) => q.gte('timestamp', haUmMinuto))
|
||||
.order('asc')
|
||||
.collect();
|
||||
|
||||
// Buscar métricas também (para mensagens se houver)
|
||||
const metricasRecentes = await ctx.db
|
||||
.query('systemMetrics')
|
||||
.withIndex('by_timestamp', (q) => q.gte('timestamp', haUmMinuto))
|
||||
.order('asc')
|
||||
.collect();
|
||||
// Buscar métricas também (para mensagens se houver)
|
||||
const metricasRecentes = await ctx.db
|
||||
.query('systemMetrics')
|
||||
.withIndex('by_timestamp', (q) => q.gte('timestamp', haUmMinuto))
|
||||
.order('asc')
|
||||
.collect();
|
||||
|
||||
// Bucketizar em 30 pontos (~2s cada) para visualização
|
||||
const numBuckets = 30;
|
||||
@@ -727,6 +741,11 @@ export const getAtividadeBancoDados = query({
|
||||
}
|
||||
|
||||
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()
|
||||
}),
|
||||
handler: async (ctx) => {
|
||||
const umaHoraAtras = Date.now() - 60 * 60 * 1000;
|
||||
try {
|
||||
const umaHoraAtras = Date.now() - 60 * 60 * 1000;
|
||||
|
||||
// Buscar atividades reais do sistema
|
||||
const atividades = await ctx.db
|
||||
.query('logsAtividades')
|
||||
.withIndex('by_timestamp', (q) => q.gte('timestamp', umaHoraAtras))
|
||||
.collect();
|
||||
// Buscar atividades reais do sistema
|
||||
const atividades = await ctx.db
|
||||
.query('logsAtividades')
|
||||
.withIndex('by_timestamp', (q) => q.gte('timestamp', umaHoraAtras))
|
||||
.collect();
|
||||
|
||||
// Buscar métricas também
|
||||
const metricas = await ctx.db
|
||||
.query('systemMetrics')
|
||||
.withIndex('by_timestamp', (q) => q.gte('timestamp', umaHoraAtras))
|
||||
.order('desc')
|
||||
.take(100);
|
||||
// Buscar métricas também
|
||||
const metricas = await ctx.db
|
||||
.query('systemMetrics')
|
||||
.withIndex('by_timestamp', (q) => q.gte('timestamp', umaHoraAtras))
|
||||
.order('desc')
|
||||
.take(100);
|
||||
|
||||
// Contar operações de leitura (consultas, visualizações)
|
||||
const leituras = atividades.filter(
|
||||
@@ -792,5 +812,10 @@ export const getDistribuicaoRequisicoes = query({
|
||||
const mutations = escritas + Math.round(totalMensagens * 0.3);
|
||||
|
||||
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 };
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user