From 8ca737c62f259d7a9ab088aef62671be375ceda3 Mon Sep 17 00:00:00 2001
From: deyvisonwanderley
Date: Wed, 5 Nov 2025 07:20:37 -0300
Subject: [PATCH] feat: enhance chat functionality with new conversation and
meeting room features
- Added support for creating and managing group conversations and meeting rooms, allowing users to initiate discussions with multiple participants.
- Implemented a modal for creating new conversations, including options for individual, group, and meeting room types.
- Enhanced the chat list component to filter and display conversations based on type, improving user navigation.
- Introduced admin functionalities for meeting rooms, enabling user management and role assignments within the chat interface.
- Updated backend schema and API to accommodate new conversation types and related operations, ensuring robust data handling.
---
.../src/lib/components/chat/ChatList.svelte | 328 +++++++----
.../src/lib/components/chat/ChatWidget.svelte | 191 ++++++-
.../src/lib/components/chat/ChatWindow.svelte | 70 ++-
.../lib/components/chat/MessageList.svelte | 70 ++-
.../chat/NewConversationModal.svelte | 311 ++++++++---
.../components/chat/SalaReuniaoManager.svelte | 376 +++++++++++++
.../(dashboard)/ti/notificacoes/+page.svelte | 16 +-
packages/backend/convex/chat.ts | 508 +++++++++++++++++-
packages/backend/convex/schema.ts | 7 +-
9 files changed, 1665 insertions(+), 212 deletions(-)
create mode 100644 apps/web/src/lib/components/chat/SalaReuniaoManager.svelte
diff --git a/apps/web/src/lib/components/chat/ChatList.svelte b/apps/web/src/lib/components/chat/ChatList.svelte
index 4ef925f..b5c9b81 100644
--- a/apps/web/src/lib/components/chat/ChatList.svelte
+++ b/apps/web/src/lib/components/chat/ChatList.svelte
@@ -6,6 +6,7 @@
import { ptBR } from "date-fns/locale";
import UserStatusBadge from "./UserStatusBadge.svelte";
import UserAvatar from "./UserAvatar.svelte";
+ import NewConversationModal from "./NewConversationModal.svelte";
const client = useConvexClient();
@@ -15,7 +16,11 @@
// Buscar o perfil do usuário logado
const meuPerfil = useQuery(api.usuarios.obterPerfil, {});
+ // Buscar conversas (grupos e salas de reunião)
+ const conversas = useQuery(api.chat.listarConversas, {});
+
let searchQuery = $state("");
+ let activeTab = $state<"usuarios" | "conversas">("usuarios");
// Debug: monitorar carregamento de dados
$effect(() => {
@@ -85,6 +90,7 @@
}
let processando = $state(false);
+ let showNewConversationModal = $state(false);
async function handleClickUsuario(usuario: any) {
if (processando) {
@@ -132,6 +138,38 @@
};
return labels[status || "offline"] || "Offline";
}
+
+ // Filtrar conversas por tipo e busca
+ const conversasFiltradas = $derived(() => {
+ if (!conversas?.data) return [];
+
+ let lista = conversas.data.filter((c: any) =>
+ c.tipo === "grupo" || c.tipo === "sala_reuniao"
+ );
+
+ // Aplicar busca
+ if (searchQuery.trim()) {
+ const query = searchQuery.toLowerCase();
+ lista = lista.filter((c: any) =>
+ c.nome?.toLowerCase().includes(query)
+ );
+ }
+
+ return lista;
+ });
+
+ function handleClickConversa(conversa: any) {
+ if (processando) return;
+ try {
+ processando = true;
+ abrirConversa(conversa._id);
+ } catch (error) {
+ console.error("Erro ao abrir conversa:", error);
+ alert(`Erro ao abrir conversa: ${error instanceof Error ? error.message : String(error)}`);
+ } finally {
+ processando = false;
+ }
+ }
@@ -161,104 +199,214 @@
-
-
-
- Usuários do Sistema ({usuariosFiltrados.length})
-
-
-
-
-
- {#if usuarios?.data && usuariosFiltrados.length > 0}
- {#each usuariosFiltrados as usuario (usuario._id)}
-
- {/each}
- {:else if !usuarios?.data}
-
-
-
-
- {:else}
-
-
+
+
+
+
+
+
+
+
+
+
+
+ Nova Conversa
+
+
+
+
+
+
+ {#if activeTab === "usuarios"}
+
+ {#if usuarios?.data && usuariosFiltrados.length > 0}
+ {#each usuariosFiltrados as usuario (usuario._id)}
+
+ {/each}
+ {:else if !usuarios?.data}
+
+
+
+
+ {:else}
+
+
+
+
Nenhum usuário encontrado
+
+ {/if}
+ {:else}
+
+ {#if conversas?.data && conversasFiltradas().length > 0}
+ {#each conversasFiltradas() as conversa (conversa._id)}
+
+ {/each}
+ {:else if !conversas?.data}
+
+
+
+
+ {:else}
+
+
+
+
Nenhuma conversa encontrada
+
Crie um grupo ou sala de reunião para começar
+
+ {/if}
{/if}
-
+
+{#if showNewConversationModal}
+ (showNewConversationModal = false)} />
+{/if}
diff --git a/apps/web/src/lib/components/chat/ChatWidget.svelte b/apps/web/src/lib/components/chat/ChatWidget.svelte
index e311d73..f014d77 100644
--- a/apps/web/src/lib/components/chat/ChatWidget.svelte
+++ b/apps/web/src/lib/components/chat/ChatWidget.svelte
@@ -43,6 +43,102 @@
let dragStart = $state({ x: 0, y: 0 });
let isAnimating = $state(false);
+ // Tamanho da janela (redimensionável)
+ const MIN_WIDTH = 300;
+ const MAX_WIDTH = 1200;
+ const MIN_HEIGHT = 400;
+ const MAX_HEIGHT = 900;
+ const DEFAULT_WIDTH = 440;
+ const DEFAULT_HEIGHT = 680;
+
+ // Carregar tamanho salvo do localStorage ou usar padrão
+ function getSavedSize() {
+ if (typeof window === 'undefined') return { width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT };
+ const saved = localStorage.getItem('chat-window-size');
+ if (saved) {
+ try {
+ const parsed = JSON.parse(saved);
+ return {
+ width: Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, parsed.width || DEFAULT_WIDTH)),
+ height: Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, parsed.height || DEFAULT_HEIGHT))
+ };
+ } catch {
+ return { width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT };
+ }
+ }
+ return { width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT };
+ }
+
+ let windowSize = $state(getSavedSize());
+
+ // Salvar tamanho no localStorage
+ function saveSize() {
+ if (typeof window !== 'undefined') {
+ localStorage.setItem('chat-window-size', JSON.stringify(windowSize));
+ }
+ }
+
+ // Redimensionamento
+ let isResizing = $state(false);
+ let resizeStart = $state({ x: 0, y: 0, width: 0, height: 0 });
+ let resizeDirection = $state(null); // 'n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw'
+
+ function handleResizeStart(e: MouseEvent, direction: string) {
+ if (e.button !== 0) return;
+ e.preventDefault();
+ e.stopPropagation();
+ isResizing = true;
+ resizeDirection = direction;
+ resizeStart = {
+ x: e.clientX,
+ y: e.clientY,
+ width: windowSize.width,
+ height: windowSize.height
+ };
+ document.body.classList.add('resizing');
+ }
+
+ function handleResizeMove(e: MouseEvent) {
+ if (!isResizing || !resizeDirection) return;
+
+ const deltaX = e.clientX - resizeStart.x;
+ const deltaY = e.clientY - resizeStart.y;
+
+ let newWidth = resizeStart.width;
+ let newHeight = resizeStart.height;
+ let newX = position.x;
+ let newY = position.y;
+
+ // Redimensionar baseado na direção
+ if (resizeDirection.includes('e')) {
+ newWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, resizeStart.width + deltaX));
+ }
+ if (resizeDirection.includes('w')) {
+ newWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, resizeStart.width - deltaX));
+ newX = position.x + (resizeStart.width - newWidth);
+ }
+ if (resizeDirection.includes('s')) {
+ newHeight = Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, resizeStart.height + deltaY));
+ }
+ if (resizeDirection.includes('n')) {
+ newHeight = Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, resizeStart.height - deltaY));
+ newY = position.y + (resizeStart.height - newHeight);
+ }
+
+ windowSize = { width: newWidth, height: newHeight };
+ position = { x: newX, y: newY };
+ }
+
+ function handleResizeEnd() {
+ if (isResizing) {
+ isResizing = false;
+ resizeDirection = null;
+ document.body.classList.remove('resizing');
+ saveSize();
+ ajustarPosicao();
+ }
+ }
+
// Sincronizar com stores
$effect(() => {
isOpen = $chatAberto;
@@ -89,14 +185,19 @@
}
function handleMouseMove(e: MouseEvent) {
+ if (isResizing) {
+ handleResizeMove(e);
+ return;
+ }
+
if (!isDragging) return;
const newX = e.clientX - dragStart.x;
const newY = e.clientY - dragStart.y;
// Dimensões do widget
- const widgetWidth = isOpen && !isMinimized ? 440 : 72;
- const widgetHeight = isOpen && !isMinimized ? 680 : 72;
+ const widgetWidth = isOpen && !isMinimized ? windowSize.width : 72;
+ const widgetHeight = isOpen && !isMinimized ? windowSize.height : 72;
// Limites da tela com margem de segurança
const minX = -(widgetWidth - 100); // Permitir até 100px visíveis
@@ -117,14 +218,15 @@
// Garantir que está dentro dos limites ao soltar
ajustarPosicao();
}
+ handleResizeEnd();
}
function ajustarPosicao() {
isAnimating = true;
// Dimensões do widget
- const widgetWidth = isOpen && !isMinimized ? 440 : 72;
- const widgetHeight = isOpen && !isMinimized ? 680 : 72;
+ const widgetWidth = isOpen && !isMinimized ? windowSize.width : 72;
+ const widgetHeight = isOpen && !isMinimized ? windowSize.height : 72;
// Verificar se está fora dos limites
let newX = position.x;
@@ -243,10 +345,10 @@
class="fixed flex flex-col overflow-hidden backdrop-blur-2xl"
style="
z-index: 99999 !important;
- bottom: {position.y === 0 ? '1.5rem' : `${window.innerHeight - position.y - 680}px`};
- right: {position.x === 0 ? '1.5rem' : `${window.innerWidth - position.x - 440}px`};
- width: 440px;
- height: 680px;
+ bottom: {position.y === 0 ? '1.5rem' : `${window.innerHeight - position.y - windowSize.height}px`};
+ right: {position.x === 0 ? '1.5rem' : `${window.innerWidth - position.x - windowSize.width}px`};
+ width: {windowSize.width}px;
+ height: {windowSize.height}px;
max-width: calc(100vw - 3rem);
max-height: calc(100vh - 3rem);
position: fixed !important;
@@ -335,6 +437,30 @@
+
+
+
+ {:else if conversa() && (conversa()?.tipo === "grupo" || conversa()?.tipo === "sala_reuniao")}
+
+
+ {conversa()?.participantesInfo?.length || 0} {conversa()?.participantesInfo?.length === 1 ? "participante" : "participantes"}
+
+ {#if conversa()?.participantesInfo && conversa()?.participantesInfo.length > 0}
+
+ {#each conversa()?.participantesInfo.slice(0, 5) as participante (participante._id)}
+
+ {#if participante.fotoPerfilUrl}
+

+ {:else if participante.avatar}
+
})
+ {:else}
+
})
+ {/if}
+
+ {/each}
+ {#if conversa()?.participantesInfo.length > 5}
+
+ +{conversa()?.participantesInfo.length - 5}
+
+ {/if}
+
+ {/if}
+
{/if}
+
+ {#if conversa()?.tipo === "sala_reuniao"}
+
+ {/if}
+
{/if}
+
+{#if showSalaManager && conversa()?.tipo === "sala_reuniao"}
+
}
+ isAdmin={isAdmin?.data ?? false}
+ onClose={() => (showSalaManager = false)}
+ />
+{/if}
+
diff --git a/apps/web/src/lib/components/chat/MessageList.svelte b/apps/web/src/lib/components/chat/MessageList.svelte
index 108f095..a3196d9 100644
--- a/apps/web/src/lib/components/chat/MessageList.svelte
+++ b/apps/web/src/lib/components/chat/MessageList.svelte
@@ -16,6 +16,7 @@
const client = useConvexClient();
const mensagens = useQuery(api.chat.obterMensagens, { conversaId, limit: 50 });
const digitando = useQuery(api.chat.obterDigitando, { conversaId });
+ const isAdmin = useQuery(api.chat.verificarSeEhAdmin, { conversaId });
let messagesContainer: HTMLDivElement;
let shouldScrollToBottom = true;
@@ -199,18 +200,31 @@
novoConteudoEditado = "";
}
- async function deletarMensagem(mensagemId: Id<"mensagens">) {
- if (!confirm("Tem certeza que deseja deletar esta mensagem?")) {
+ async function deletarMensagem(mensagemId: Id<"mensagens">, isAdminDeleting: boolean = false) {
+ const mensagemTexto = isAdminDeleting
+ ? "Tem certeza que deseja deletar esta mensagem como administrador? O remetente será notificado."
+ : "Tem certeza que deseja deletar esta mensagem?";
+
+ if (!confirm(mensagemTexto)) {
return;
}
try {
- await client.mutation(api.chat.deletarMensagem, {
- mensagemId,
- });
- } catch (error) {
+ if (isAdminDeleting) {
+ const resultado = await client.mutation(api.chat.deletarMensagemComoAdmin, {
+ mensagemId,
+ });
+ if (!resultado.sucesso) {
+ alert(resultado.erro || "Erro ao deletar mensagem");
+ }
+ } else {
+ await client.mutation(api.chat.deletarMensagem, {
+ mensagemId,
+ });
+ }
+ } catch (error: any) {
console.error("Erro ao deletar mensagem:", error);
- alert("Erro ao deletar mensagem");
+ alert(error.message || "Erro ao deletar mensagem");
}
}
@@ -437,22 +451,34 @@
{formatarDataMensagem(mensagem.enviadaEm)}
- {#if isMinha && !mensagem.deletada && !mensagem.agendadaPara}
+ {#if !mensagem.deletada && !mensagem.agendadaPara}
-
-
+ {#if isMinha}
+
+
+
+ {:else if isAdmin?.data}
+
+
+ {/if}
{/if}
diff --git a/apps/web/src/lib/components/chat/NewConversationModal.svelte b/apps/web/src/lib/components/chat/NewConversationModal.svelte
index f691ea3..965401a 100644
--- a/apps/web/src/lib/components/chat/NewConversationModal.svelte
+++ b/apps/web/src/lib/components/chat/NewConversationModal.svelte
@@ -2,6 +2,7 @@
import { useQuery, useConvexClient } from "convex-svelte";
import { api } from "@sgse-app/backend/convex/_generated/api";
import { abrirConversa } from "$lib/stores/chatStore";
+ import { authStore } from "$lib/stores/auth.svelte";
import UserStatusBadge from "./UserStatusBadge.svelte";
import UserAvatar from "./UserAvatar.svelte";
@@ -12,24 +13,42 @@
let { onClose }: Props = $props();
const client = useConvexClient();
- const usuarios = useQuery(api.chat.listarTodosUsuarios, {});
+ const usuarios = useQuery(api.usuarios.listarParaChat, {});
+ const meuPerfil = useQuery(api.usuarios.obterPerfil, {});
- let activeTab = $state<"individual" | "grupo">("individual");
+ let activeTab = $state<"individual" | "grupo" | "sala_reuniao">("individual");
let searchQuery = $state("");
let selectedUsers = $state([]);
let groupName = $state("");
+ let salaReuniaoName = $state("");
let loading = $state(false);
const usuariosFiltrados = $derived(() => {
- if (!usuarios) return [];
- if (!searchQuery.trim()) return usuarios;
-
- const query = searchQuery.toLowerCase();
- return usuarios.filter((u: any) =>
- u.nome.toLowerCase().includes(query) ||
- u.email.toLowerCase().includes(query) ||
- u.matricula.toLowerCase().includes(query)
- );
+ if (!usuarios?.data) return [];
+
+ // Filtrar o próprio usuário
+ const meuId = authStore.usuario?._id || meuPerfil?.data?._id;
+ let lista = usuarios.data.filter((u: any) => u._id !== meuId);
+
+ // Aplicar busca
+ if (searchQuery.trim()) {
+ const query = searchQuery.toLowerCase();
+ lista = lista.filter((u: any) =>
+ u.nome?.toLowerCase().includes(query) ||
+ u.email?.toLowerCase().includes(query) ||
+ u.matricula?.toLowerCase().includes(query)
+ );
+ }
+
+ // Ordenar: online primeiro, depois por nome
+ return lista.sort((a: any, b: any) => {
+ const statusOrder = { online: 0, ausente: 1, externo: 2, em_reuniao: 3, offline: 4 };
+ const statusA = statusOrder[a.statusPresenca as keyof typeof statusOrder] ?? 4;
+ const statusB = statusOrder[b.statusPresenca as keyof typeof statusOrder] ?? 4;
+
+ if (statusA !== statusB) return statusA - statusB;
+ return (a.nome || "").localeCompare(b.nome || "");
+ });
});
function toggleUserSelection(userId: string) {
@@ -77,26 +96,63 @@
});
abrirConversa(conversaId);
onClose();
- } catch (error) {
+ } catch (error: any) {
console.error("Erro ao criar grupo:", error);
- alert("Erro ao criar grupo");
+ const mensagem = error?.message || error?.data || "Erro desconhecido ao criar grupo";
+ alert(`Erro ao criar grupo: ${mensagem}`);
+ } finally {
+ loading = false;
+ }
+ }
+
+ async function handleCriarSalaReuniao() {
+ if (selectedUsers.length < 1) {
+ alert("Selecione pelo menos 1 participante");
+ return;
+ }
+
+ if (!salaReuniaoName.trim()) {
+ alert("Digite um nome para a sala de reunião");
+ return;
+ }
+
+ try {
+ loading = true;
+ const conversaId = await client.mutation(api.chat.criarSalaReuniao, {
+ nome: salaReuniaoName.trim(),
+ participantes: selectedUsers as any,
+ });
+ abrirConversa(conversaId);
+ onClose();
+ } catch (error: any) {
+ console.error("Erro ao criar sala de reunião:", error);
+ const mensagem = error?.message || error?.data || "Erro desconhecido ao criar sala de reunião";
+ alert(`Erro ao criar sala de reunião: ${mensagem}`);
} finally {
loading = false;
}
}
-
+
e.stopPropagation()}
+ style="box-shadow: 0 20px 60px -15px rgba(0, 0, 0, 0.3);"
>
-
-
-
Nova Conversa
+
+
+
+
+
+ Nova Conversa
+
-
-
+
+
+
-
+
{#if activeTab === "grupo"}
-
-
-
-
- Participantes {selectedUsers.length > 0 ? `(${selectedUsers.length})` : ""}
+
+
+
+ Participantes {selectedUsers.length > 0 ? `(${selectedUsers.length} selecionado${selectedUsers.length > 1 ? 's' : ''})` : ""}
+
+
+
+ {:else if activeTab === "sala_reuniao"}
+
+
+
+ Nome da Sala de Reunião
+ 👑 Você será o administrador
+
+
+
+
+
+
+
+ Participantes {selectedUsers.length > 0 ? `(${selectedUsers.length} selecionado${selectedUsers.length > 1 ? 's' : ''})` : ""}
{/if}
-
-
+
+
- {#if usuarios && usuariosFiltrados().length > 0}
+ {#if usuarios?.data && usuariosFiltrados().length > 0}
{#each usuariosFiltrados() as usuario (usuario._id)}
+ {@const isSelected = selectedUsers.includes(usuario._id)}