feat: enhance chat components with improved accessibility features, including ARIA attributes for search and user status, and implement message length validation and file type checks in message input handling
This commit is contained in:
@@ -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(() => {
|
||||||
const usuario = currentUser?.data;
|
// Priorizar perfil (tem mais informações)
|
||||||
if (!usuario) return null;
|
const perfil = meuPerfilQuery?.data;
|
||||||
|
if (perfil?.fotoPerfilUrl) {
|
||||||
|
return perfil.fotoPerfilUrl;
|
||||||
|
}
|
||||||
|
|
||||||
// Prioridade: fotoPerfilUrl > avatar > fallback com nome
|
// Fallback para currentUser
|
||||||
if (usuario.fotoPerfilUrl) {
|
const usuario = currentUser?.data;
|
||||||
|
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,45 +423,27 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 conversas = todasConversas.data as ConversaComTimestamp[];
|
const tempoDesdeUltimaExecucao = agora - ultimaExecucaoNotificacao;
|
||||||
|
|
||||||
// Encontrar conversas com novas mensagens
|
// Throttle: só executar se passou tempo suficiente
|
||||||
// Obter ID do usuário logado de forma robusta
|
if (tempoDesdeUltimaExecucao < THROTTLE_NOTIFICACAO_MS && ultimaExecucaoNotificacao > 0) {
|
||||||
// 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 (todasConversas?.data && meuId()) {
|
||||||
if (import.meta.env.DEV) {
|
ultimaExecucaoNotificacao = agora;
|
||||||
console.log('🔍 [ChatWidget] Usuário logado identificado:', {
|
const conversas = todasConversas.data as ConversaComTimestamp[];
|
||||||
id: meuId,
|
const meuIdAtual = meuId();
|
||||||
fonte: perfilConvex ? 'Convex Query' : 'CurrentUser',
|
|
||||||
nome: usuarioLogado?.nome || perfilConvex?.nome,
|
if (!meuIdAtual) {
|
||||||
email: usuarioLogado?.email
|
console.warn('⚠️ [ChatWidget] Não foi possível identificar o ID do usuário logado');
|
||||||
});
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
conversas.forEach((conv) => {
|
conversas.forEach((conv) => {
|
||||||
@@ -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;
|
||||||
@@ -188,6 +238,12 @@
|
|||||||
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'>[] = [];
|
||||||
const mentionRegex = /@(\w+)/g;
|
const mentionRegex = /@(\w+)/g;
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.ok) {
|
xhr.addEventListener('load', () => {
|
||||||
throw new Error('Falha no upload');
|
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}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const { storageId } = await result.json();
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
const storageId = await uploadPromise;
|
||||||
|
|
||||||
// 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,10 +530,12 @@
|
|||||||
|
|
||||||
<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 -->
|
||||||
|
<div class="relative shrink-0">
|
||||||
<label
|
<label
|
||||||
class="group relative flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-xl transition-all duration-300"
|
class="group relative flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-xl transition-all duration-300"
|
||||||
style="background: rgba(102, 126, 234, 0.1); border: 1px solid rgba(102, 126, 234, 0.2);"
|
style="background: rgba(102, 126, 234, 0.1); border: 1px solid rgba(102, 126, 234, 0.2);"
|
||||||
title="Anexar arquivo"
|
title="Anexar arquivo"
|
||||||
|
aria-label="Anexar arquivo"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
@@ -394,6 +543,7 @@
|
|||||||
onchange={handleFileUpload}
|
onchange={handleFileUpload}
|
||||||
disabled={uploadingFile || enviando}
|
disabled={uploadingFile || enviando}
|
||||||
accept="*/*"
|
accept="*/*"
|
||||||
|
aria-label="Selecionar arquivo para anexar"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
class="bg-primary/0 group-hover:bg-primary/10 absolute inset-0 transition-colors duration-300"
|
class="bg-primary/0 group-hover:bg-primary/10 absolute inset-0 transition-colors duration-300"
|
||||||
@@ -409,6 +559,20 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</label>
|
</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}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Botão de EMOJI MODERNO -->
|
<!-- Botão de EMOJI MODERNO -->
|
||||||
<div class="relative shrink-0">
|
<div class="relative shrink-0">
|
||||||
<button
|
<button
|
||||||
@@ -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"
|
||||||
@@ -508,7 +701,8 @@
|
|||||||
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"
|
||||||
@@ -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,18 +267,43 @@
|
|||||||
};
|
};
|
||||||
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:
|
||||||
|
// 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
|
// Usar requestAnimationFrame para garantir que o DOM foi atualizado
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
tick().then(() => {
|
tick().then(() => {
|
||||||
@@ -207,15 +313,34 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
} else if (shouldScrollToBottom) {
|
||||||
|
// Se não é nova mensagem mas o usuário estava no final, manter no final
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
tick().then(() => {
|
||||||
|
if (messagesContainer) {
|
||||||
|
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
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,6 +612,7 @@ export const getStatusSistema = query({
|
|||||||
ultimaAtualizacao: v.number()
|
ultimaAtualizacao: v.number()
|
||||||
}),
|
}),
|
||||||
handler: async (ctx) => {
|
handler: async (ctx) => {
|
||||||
|
try {
|
||||||
// Última métrica, se existir
|
// Última métrica, se existir
|
||||||
const ultimaMetrica = (await ctx.db.query('systemMetrics').order('desc').first()) ?? null;
|
const ultimaMetrica = (await ctx.db.query('systemMetrics').order('desc').first()) ?? null;
|
||||||
|
|
||||||
@@ -655,6 +656,18 @@ export const getStatusSistema = query({
|
|||||||
memoriaUsada,
|
memoriaUsada,
|
||||||
ultimaAtualizacao
|
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()
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -673,6 +686,7 @@ export const getAtividadeBancoDados = query({
|
|||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
handler: async (ctx) => {
|
handler: async (ctx) => {
|
||||||
|
try {
|
||||||
const agora = Date.now();
|
const agora = Date.now();
|
||||||
const haUmMinuto = agora - 60 * 1000;
|
const haUmMinuto = agora - 60 * 1000;
|
||||||
|
|
||||||
@@ -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,6 +761,7 @@ export const getDistribuicaoRequisicoes = query({
|
|||||||
escritas: v.number()
|
escritas: v.number()
|
||||||
}),
|
}),
|
||||||
handler: async (ctx) => {
|
handler: async (ctx) => {
|
||||||
|
try {
|
||||||
const umaHoraAtras = Date.now() - 60 * 60 * 1000;
|
const umaHoraAtras = Date.now() - 60 * 60 * 1000;
|
||||||
|
|
||||||
// Buscar atividades reais do sistema
|
// Buscar atividades reais do sistema
|
||||||
@@ -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