Ajustes gerais #57
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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'>} />
|
||||||
|
|||||||
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 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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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]} />
|
||||||
|
|||||||
@@ -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'
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
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();
|
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'
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -73,3 +73,5 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -73,3 +73,5 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
// Ú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
|
// 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 };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user