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.
This commit is contained in:
2025-11-05 07:20:37 -03:00
parent aa3e3470cd
commit 8ca737c62f
9 changed files with 1665 additions and 212 deletions

View File

@@ -6,6 +6,7 @@
import { ptBR } from "date-fns/locale"; import { ptBR } from "date-fns/locale";
import UserStatusBadge from "./UserStatusBadge.svelte"; import UserStatusBadge from "./UserStatusBadge.svelte";
import UserAvatar from "./UserAvatar.svelte"; import UserAvatar from "./UserAvatar.svelte";
import NewConversationModal from "./NewConversationModal.svelte";
const client = useConvexClient(); const client = useConvexClient();
@@ -15,7 +16,11 @@
// Buscar o perfil do usuário logado // Buscar o perfil do usuário logado
const meuPerfil = useQuery(api.usuarios.obterPerfil, {}); const meuPerfil = useQuery(api.usuarios.obterPerfil, {});
// Buscar conversas (grupos e salas de reunião)
const conversas = useQuery(api.chat.listarConversas, {});
let searchQuery = $state(""); let searchQuery = $state("");
let activeTab = $state<"usuarios" | "conversas">("usuarios");
// Debug: monitorar carregamento de dados // Debug: monitorar carregamento de dados
$effect(() => { $effect(() => {
@@ -85,6 +90,7 @@
} }
let processando = $state(false); let processando = $state(false);
let showNewConversationModal = $state(false);
async function handleClickUsuario(usuario: any) { async function handleClickUsuario(usuario: any) {
if (processando) { if (processando) {
@@ -132,6 +138,38 @@
}; };
return labels[status || "offline"] || "Offline"; 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;
}
}
</script> </script>
<div class="flex flex-col h-full"> <div class="flex flex-col h-full">
@@ -161,104 +199,214 @@
</div> </div>
</div> </div>
<!-- Título da Lista --> <!-- Tabs e Título -->
<div class="p-4 border-b border-base-300 bg-base-200"> <div class="border-b border-base-300 bg-base-200">
<h3 class="font-semibold text-sm text-base-content/70 uppercase tracking-wide"> <!-- Tabs -->
Usuários do Sistema ({usuariosFiltrados.length}) <div class="tabs tabs-boxed p-2">
</h3> <button
</div> type="button"
class={`tab flex-1 ${activeTab === "usuarios" ? "tab-active" : ""}`}
onclick={() => (activeTab = "usuarios")}
>
👥 Usuários ({usuariosFiltrados.length})
</button>
<button
type="button"
class={`tab flex-1 ${activeTab === "conversas" ? "tab-active" : ""}`}
onclick={() => (activeTab = "conversas")}
>
💬 Conversas ({conversasFiltradas().length})
</button>
</div>
<!-- Lista de usuários --> <!-- Botão Nova Conversa -->
<div class="flex-1 overflow-y-auto"> <div class="px-4 pb-2 flex justify-end">
{#if usuarios?.data && usuariosFiltrados.length > 0} <button
{#each usuariosFiltrados as usuario (usuario._id)} type="button"
<button class="btn btn-primary btn-sm"
type="button" onclick={() => (showNewConversationModal = true)}
class="w-full text-left px-4 py-3 hover:bg-base-200 border-b border-base-300 transition-colors flex items-center gap-3 {processando ? 'opacity-50 cursor-wait' : 'cursor-pointer'}" title="Nova conversa (grupo ou sala de reunião)"
onclick={() => handleClickUsuario(usuario)} aria-label="Nova conversa"
disabled={processando} >
>
<!-- Ícone de mensagem -->
<div class="flex-shrink-0 w-10 h-10 rounded-xl flex items-center justify-center transition-all duration-300 hover:scale-110"
style="background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%); border: 1px solid rgba(102, 126, 234, 0.2);">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5 text-primary"
>
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
<path d="M9 10h.01M15 10h.01"/>
</svg>
</div>
<!-- Avatar -->
<div class="relative flex-shrink-0">
<UserAvatar
avatar={usuario.avatar}
fotoPerfilUrl={usuario.fotoPerfilUrl}
nome={usuario.nome}
size="md"
/>
<!-- Status badge -->
<div class="absolute bottom-0 right-0">
<UserStatusBadge status={usuario.statusPresenca || "offline"} size="sm" />
</div>
</div>
<!-- Conteúdo -->
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between mb-1">
<p class="font-semibold text-base-content truncate">
{usuario.nome}
</p>
<span class="text-xs px-2 py-0.5 rounded-full {
usuario.statusPresenca === 'online' ? 'bg-success/20 text-success' :
usuario.statusPresenca === 'ausente' ? 'bg-warning/20 text-warning' :
usuario.statusPresenca === 'em_reuniao' ? 'bg-error/20 text-error' :
'bg-base-300 text-base-content/50'
}">
{getStatusLabel(usuario.statusPresenca)}
</span>
</div>
<div class="flex items-center gap-2">
<p class="text-sm text-base-content/70 truncate">
{usuario.statusMensagem || usuario.email}
</p>
</div>
</div>
</button>
{/each}
{:else if !usuarios?.data}
<!-- Loading -->
<div class="flex items-center justify-center h-full">
<span class="loading loading-spinner loading-lg"></span>
</div>
{:else}
<!-- Nenhum usuário encontrado -->
<div class="flex flex-col items-center justify-center h-full text-center px-4">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke-width="1.5" stroke-width="2"
stroke="currentColor" stroke="currentColor"
class="w-16 h-16 text-base-content/30 mb-4" class="w-4 h-4 mr-1"
> >
<path <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
stroke-linecap="round"
stroke-linejoin="round"
d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z"
/>
</svg> </svg>
<p class="text-base-content/70">Nenhum usuário encontrado</p> Nova Conversa
</div> </button>
</div>
</div>
<!-- Lista de conteúdo -->
<div class="flex-1 overflow-y-auto">
{#if activeTab === "usuarios"}
<!-- Lista de usuários -->
{#if usuarios?.data && usuariosFiltrados.length > 0}
{#each usuariosFiltrados as usuario (usuario._id)}
<button
type="button"
class="w-full text-left px-4 py-3 hover:bg-base-200 border-b border-base-300 transition-colors flex items-center gap-3 {processando ? 'opacity-50 cursor-wait' : 'cursor-pointer'}"
onclick={() => handleClickUsuario(usuario)}
disabled={processando}
>
<!-- Ícone de mensagem -->
<div class="flex-shrink-0 w-10 h-10 rounded-xl flex items-center justify-center transition-all duration-300 hover:scale-110"
style="background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%); border: 1px solid rgba(102, 126, 234, 0.2);">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5 text-primary"
>
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
<path d="M9 10h.01M15 10h.01"/>
</svg>
</div>
<!-- Avatar -->
<div class="relative flex-shrink-0">
<UserAvatar
avatar={usuario.avatar}
fotoPerfilUrl={usuario.fotoPerfilUrl}
nome={usuario.nome}
size="md"
/>
<!-- Status badge -->
<div class="absolute bottom-0 right-0">
<UserStatusBadge status={usuario.statusPresenca || "offline"} size="sm" />
</div>
</div>
<!-- Conteúdo -->
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between mb-1">
<p class="font-semibold text-base-content truncate">
{usuario.nome}
</p>
<span class="text-xs px-2 py-0.5 rounded-full {
usuario.statusPresenca === 'online' ? 'bg-success/20 text-success' :
usuario.statusPresenca === 'ausente' ? 'bg-warning/20 text-warning' :
usuario.statusPresenca === 'em_reuniao' ? 'bg-error/20 text-error' :
'bg-base-300 text-base-content/50'
}">
{getStatusLabel(usuario.statusPresenca)}
</span>
</div>
<div class="flex items-center gap-2">
<p class="text-sm text-base-content/70 truncate">
{usuario.statusMensagem || usuario.email}
</p>
</div>
</div>
</button>
{/each}
{:else if !usuarios?.data}
<!-- Loading -->
<div class="flex items-center justify-center h-full">
<span class="loading loading-spinner loading-lg"></span>
</div>
{:else}
<!-- Nenhum usuário encontrado -->
<div class="flex flex-col items-center justify-center h-full text-center px-4">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-16 h-16 text-base-content/30 mb-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z"
/>
</svg>
<p class="text-base-content/70">Nenhum usuário encontrado</p>
</div>
{/if}
{:else}
<!-- Lista de conversas (grupos e salas) -->
{#if conversas?.data && conversasFiltradas().length > 0}
{#each conversasFiltradas() as conversa (conversa._id)}
<button
type="button"
class="w-full text-left px-4 py-3 hover:bg-base-200 border-b border-base-300 transition-colors flex items-center gap-3 {processando ? 'opacity-50 cursor-wait' : 'cursor-pointer'}"
onclick={() => handleClickConversa(conversa)}
disabled={processando}
>
<!-- Ícone de grupo/sala -->
<div class="flex-shrink-0 w-10 h-10 rounded-xl flex items-center justify-center transition-all duration-300 hover:scale-110 {
conversa.tipo === 'sala_reuniao'
? 'bg-gradient-to-br from-blue-500/20 to-purple-500/20 border border-blue-300/30'
: 'bg-gradient-to-br from-primary/20 to-secondary/20 border border-primary/30'
}">
{#if conversa.tipo === "sala_reuniao"}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5 text-blue-500">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" />
</svg>
{:else}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5 text-primary">
<path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z" />
</svg>
{/if}
</div>
<!-- Conteúdo -->
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between mb-1">
<p class="font-semibold text-base-content truncate">
{conversa.nome || (conversa.tipo === "sala_reuniao" ? "Sala sem nome" : "Grupo sem nome")}
</p>
{#if conversa.naoLidas > 0}
<span class="badge badge-primary badge-sm">{conversa.naoLidas}</span>
{/if}
</div>
<div class="flex items-center gap-2">
<span class="text-xs px-2 py-0.5 rounded-full {
conversa.tipo === 'sala_reuniao' ? 'bg-blue-500/20 text-blue-500' : 'bg-primary/20 text-primary'
}">
{conversa.tipo === "sala_reuniao" ? "👑 Sala de Reunião" : "👥 Grupo"}
</span>
{#if conversa.participantesInfo}
<span class="text-xs text-base-content/50">
{conversa.participantesInfo.length} participante{conversa.participantesInfo.length !== 1 ? 's' : ''}
</span>
{/if}
</div>
</div>
</button>
{/each}
{:else if !conversas?.data}
<!-- Loading -->
<div class="flex items-center justify-center h-full">
<span class="loading loading-spinner loading-lg"></span>
</div>
{:else}
<!-- Nenhuma conversa encontrada -->
<div class="flex flex-col items-center justify-center h-full text-center px-4">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-16 h-16 text-base-content/30 mb-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 0 1-.825-.242m9.345-8.334a2.126 2.126 0 0 0-.476-.095 48.64 48.64 0 0 0-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0 0 11.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155" />
</svg>
<p class="text-base-content/70 font-medium mb-2">Nenhuma conversa encontrada</p>
<p class="text-sm text-base-content/50">Crie um grupo ou sala de reunião para começar</p>
</div>
{/if}
{/if} {/if}
</div> </div>
</div> </div>
<!-- Modal de Nova Conversa -->
{#if showNewConversationModal}
<NewConversationModal onClose={() => (showNewConversationModal = false)} />
{/if}

View File

@@ -43,6 +43,102 @@
let dragStart = $state({ x: 0, y: 0 }); let dragStart = $state({ x: 0, y: 0 });
let isAnimating = $state(false); 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<string | null>(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 // Sincronizar com stores
$effect(() => { $effect(() => {
isOpen = $chatAberto; isOpen = $chatAberto;
@@ -89,14 +185,19 @@
} }
function handleMouseMove(e: MouseEvent) { function handleMouseMove(e: MouseEvent) {
if (isResizing) {
handleResizeMove(e);
return;
}
if (!isDragging) return; if (!isDragging) return;
const newX = e.clientX - dragStart.x; const newX = e.clientX - dragStart.x;
const newY = e.clientY - dragStart.y; const newY = e.clientY - dragStart.y;
// Dimensões do widget // Dimensões do widget
const widgetWidth = isOpen && !isMinimized ? 440 : 72; const widgetWidth = isOpen && !isMinimized ? windowSize.width : 72;
const widgetHeight = isOpen && !isMinimized ? 680 : 72; const widgetHeight = isOpen && !isMinimized ? windowSize.height : 72;
// Limites da tela com margem de segurança // Limites da tela com margem de segurança
const minX = -(widgetWidth - 100); // Permitir até 100px visíveis const minX = -(widgetWidth - 100); // Permitir até 100px visíveis
@@ -117,14 +218,15 @@
// Garantir que está dentro dos limites ao soltar // Garantir que está dentro dos limites ao soltar
ajustarPosicao(); ajustarPosicao();
} }
handleResizeEnd();
} }
function ajustarPosicao() { function ajustarPosicao() {
isAnimating = true; isAnimating = true;
// Dimensões do widget // Dimensões do widget
const widgetWidth = isOpen && !isMinimized ? 440 : 72; const widgetWidth = isOpen && !isMinimized ? windowSize.width : 72;
const widgetHeight = isOpen && !isMinimized ? 680 : 72; const widgetHeight = isOpen && !isMinimized ? windowSize.height : 72;
// Verificar se está fora dos limites // Verificar se está fora dos limites
let newX = position.x; let newX = position.x;
@@ -243,10 +345,10 @@
class="fixed flex flex-col overflow-hidden backdrop-blur-2xl" class="fixed flex flex-col overflow-hidden backdrop-blur-2xl"
style=" style="
z-index: 99999 !important; z-index: 99999 !important;
bottom: {position.y === 0 ? '1.5rem' : `${window.innerHeight - position.y - 680}px`}; bottom: {position.y === 0 ? '1.5rem' : `${window.innerHeight - position.y - windowSize.height}px`};
right: {position.x === 0 ? '1.5rem' : `${window.innerWidth - position.x - 440}px`}; right: {position.x === 0 ? '1.5rem' : `${window.innerWidth - position.x - windowSize.width}px`};
width: 440px; width: {windowSize.width}px;
height: 680px; height: {windowSize.height}px;
max-width: calc(100vw - 3rem); max-width: calc(100vw - 3rem);
max-height: calc(100vh - 3rem); max-height: calc(100vh - 3rem);
position: fixed !important; position: fixed !important;
@@ -335,6 +437,30 @@
</svg> </svg>
</button> </button>
<!-- Botão maximizar MODERNO -->
<button
type="button"
class="flex items-center justify-center w-10 h-10 rounded-xl transition-all duration-300 group relative overflow-hidden"
style="background: rgba(255,255,255,0.15); backdrop-filter: blur(10px); border: 1px solid rgba(255,255,255,0.2);"
onclick={handleMaximize}
aria-label="Maximizar"
>
<div class="absolute inset-0 bg-white/0 group-hover:bg-white/20 transition-colors duration-300"></div>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5 relative z-10 group-hover:scale-110 transition-transform duration-300"
style="filter: drop-shadow(0 2px 4px rgba(0,0,0,0.2));"
>
<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/>
</svg>
</button>
<!-- Botão fechar MODERNO --> <!-- Botão fechar MODERNO -->
<button <button
type="button" type="button"
@@ -363,12 +489,59 @@
</div> </div>
<!-- Conteúdo --> <!-- Conteúdo -->
<div class="flex-1 overflow-hidden"> <div class="flex-1 overflow-hidden relative">
{#if !activeConversation} {#if !activeConversation}
<ChatList /> <ChatList />
{:else} {:else}
<ChatWindow conversaId={activeConversation} /> <ChatWindow conversaId={activeConversation} />
{/if} {/if}
<!-- Resize Handles -->
<!-- Top -->
<div
class="absolute top-0 left-0 right-0 h-2 cursor-ns-resize hover:bg-primary/20 transition-colors z-50"
onmousedown={(e) => handleResizeStart(e, 'n')}
style="border-radius: 24px 24px 0 0;"
></div>
<!-- Bottom -->
<div
class="absolute bottom-0 left-0 right-0 h-2 cursor-ns-resize hover:bg-primary/20 transition-colors z-50"
onmousedown={(e) => handleResizeStart(e, 's')}
style="border-radius: 0 0 24px 24px;"
></div>
<!-- Left -->
<div
class="absolute top-0 bottom-0 left-0 w-2 cursor-ew-resize hover:bg-primary/20 transition-colors z-50"
onmousedown={(e) => handleResizeStart(e, 'w')}
style="border-radius: 24px 0 0 24px;"
></div>
<!-- Right -->
<div
class="absolute top-0 bottom-0 right-0 w-2 cursor-ew-resize hover:bg-primary/20 transition-colors z-50"
onmousedown={(e) => handleResizeStart(e, 'e')}
style="border-radius: 0 24px 24px 0;"
></div>
<!-- Corners -->
<div
class="absolute top-0 left-0 w-4 h-4 cursor-nwse-resize hover:bg-primary/20 transition-colors z-50"
onmousedown={(e) => handleResizeStart(e, 'nw')}
style="border-radius: 24px 0 0 0;"
></div>
<div
class="absolute top-0 right-0 w-4 h-4 cursor-nesw-resize hover:bg-primary/20 transition-colors z-50"
onmousedown={(e) => handleResizeStart(e, 'ne')}
style="border-radius: 0 24px 0 0;"
></div>
<div
class="absolute bottom-0 left-0 w-4 h-4 cursor-nesw-resize hover:bg-primary/20 transition-colors z-50"
onmousedown={(e) => handleResizeStart(e, 'sw')}
style="border-radius: 0 0 0 24px;"
></div>
<div
class="absolute bottom-0 right-0 w-4 h-4 cursor-nwse-resize hover:bg-primary/20 transition-colors z-50"
onmousedown={(e) => handleResizeStart(e, 'se')}
style="border-radius: 0 0 24px 0;"
></div>
</div> </div>
</div> </div>
{/if} {/if}

View File

@@ -8,6 +8,8 @@
import UserStatusBadge from "./UserStatusBadge.svelte"; import UserStatusBadge from "./UserStatusBadge.svelte";
import UserAvatar from "./UserAvatar.svelte"; import UserAvatar from "./UserAvatar.svelte";
import ScheduleMessageModal from "./ScheduleMessageModal.svelte"; import ScheduleMessageModal from "./ScheduleMessageModal.svelte";
import SalaReuniaoManager from "./SalaReuniaoManager.svelte";
import { getAvatarUrl } from "$lib/utils/avatarGenerator";
interface Props { interface Props {
conversaId: string; conversaId: string;
@@ -16,8 +18,10 @@
let { conversaId }: Props = $props(); let { conversaId }: Props = $props();
let showScheduleModal = $state(false); let showScheduleModal = $state(false);
let showSalaManager = $state(false);
const conversas = useQuery(api.chat.listarConversas, {}); const conversas = useQuery(api.chat.listarConversas, {});
const isAdmin = useQuery(api.chat.verificarSeEhAdmin, { conversaId: conversaId as any });
const conversa = $derived(() => { const conversa = $derived(() => {
console.log("🔍 [ChatWindow] Buscando conversa ID:", conversaId); console.log("🔍 [ChatWindow] Buscando conversa ID:", conversaId);
@@ -36,8 +40,8 @@
function getNomeConversa(): string { function getNomeConversa(): string {
const c = conversa(); const c = conversa();
if (!c) return "Carregando..."; if (!c) return "Carregando...";
if (c.tipo === "grupo") { if (c.tipo === "grupo" || c.tipo === "sala_reuniao") {
return c.nome || "Grupo sem nome"; return c.nome || (c.tipo === "sala_reuniao" ? "Sala sem nome" : "Grupo sem nome");
} }
return c.outroUsuario?.nome || "Usuário"; return c.outroUsuario?.nome || "Usuário";
} }
@@ -135,11 +139,64 @@
? "Externo" ? "Externo"
: "Offline"} : "Offline"}
</p> </p>
{:else if conversa() && (conversa()?.tipo === "grupo" || conversa()?.tipo === "sala_reuniao")}
<div class="flex items-center gap-2 mt-1">
<p class="text-xs text-base-content/60">
{conversa()?.participantesInfo?.length || 0} {conversa()?.participantesInfo?.length === 1 ? "participante" : "participantes"}
</p>
{#if conversa()?.participantesInfo && conversa()?.participantesInfo.length > 0}
<div class="flex -space-x-2">
{#each conversa()?.participantesInfo.slice(0, 5) as participante (participante._id)}
<div class="relative w-5 h-5 rounded-full border-2 border-base-200 overflow-hidden bg-base-200" title={participante.nome}>
{#if participante.fotoPerfilUrl}
<img src={participante.fotoPerfilUrl} alt={participante.nome} class="w-full h-full object-cover" />
{:else if participante.avatar}
<img src={getAvatarUrl(participante.avatar)} alt={participante.nome} class="w-full h-full object-cover" />
{:else}
<img src={getAvatarUrl(participante.nome)} alt={participante.nome} class="w-full h-full object-cover" />
{/if}
</div>
{/each}
{#if conversa()?.participantesInfo.length > 5}
<div class="w-5 h-5 rounded-full border-2 border-base-200 bg-base-300 flex items-center justify-center text-[8px] font-semibold text-base-content/70" title={`+${conversa()?.participantesInfo.length - 5} mais`}>
+{conversa()?.participantesInfo.length - 5}
</div>
{/if}
</div>
{/if}
</div>
{/if} {/if}
</div> </div>
<!-- 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 Gerenciar Sala (apenas para salas de reunião) -->
{#if conversa()?.tipo === "sala_reuniao"}
<button
type="button"
class="flex items-center justify-center w-9 h-9 rounded-lg transition-all duration-300 group relative overflow-hidden"
style="background: rgba(59, 130, 246, 0.1); border: 1px solid rgba(59, 130, 246, 0.2);"
onclick={() => (showSalaManager = true)}
aria-label="Gerenciar sala"
title="Gerenciar sala de reunião"
>
<div class="absolute inset-0 bg-blue-500/0 group-hover:bg-blue-500/10 transition-colors duration-300"></div>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5 text-blue-500 relative z-10 group-hover:scale-110 transition-transform"
>
<circle cx="12" cy="12" r="3"/>
<path d="M12 1v6m0 6v6M5.64 5.64l4.24 4.24m4.24 4.24l4.24 4.24M1 12h6m6 0h6M5.64 18.36l4.24-4.24m4.24-4.24l4.24-4.24"/>
</svg>
</button>
{/if}
<!-- Botão Agendar MODERNO --> <!-- Botão Agendar MODERNO -->
<button <button
type="button" type="button"
@@ -186,3 +243,12 @@
/> />
{/if} {/if}
<!-- Modal de Gerenciamento de Sala -->
{#if showSalaManager && conversa()?.tipo === "sala_reuniao"}
<SalaReuniaoManager
conversaId={conversaId as Id<"conversas">}
isAdmin={isAdmin?.data ?? false}
onClose={() => (showSalaManager = false)}
/>
{/if}

View File

@@ -16,6 +16,7 @@
const client = useConvexClient(); const client = useConvexClient();
const mensagens = useQuery(api.chat.obterMensagens, { conversaId, limit: 50 }); const mensagens = useQuery(api.chat.obterMensagens, { conversaId, limit: 50 });
const digitando = useQuery(api.chat.obterDigitando, { conversaId }); const digitando = useQuery(api.chat.obterDigitando, { conversaId });
const isAdmin = useQuery(api.chat.verificarSeEhAdmin, { conversaId });
let messagesContainer: HTMLDivElement; let messagesContainer: HTMLDivElement;
let shouldScrollToBottom = true; let shouldScrollToBottom = true;
@@ -199,18 +200,31 @@
novoConteudoEditado = ""; novoConteudoEditado = "";
} }
async function deletarMensagem(mensagemId: Id<"mensagens">) { async function deletarMensagem(mensagemId: Id<"mensagens">, isAdminDeleting: boolean = false) {
if (!confirm("Tem certeza que deseja deletar esta mensagem?")) { 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; return;
} }
try { try {
await client.mutation(api.chat.deletarMensagem, { if (isAdminDeleting) {
mensagemId, const resultado = await client.mutation(api.chat.deletarMensagemComoAdmin, {
}); mensagemId,
} catch (error) { });
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); console.error("Erro ao deletar mensagem:", error);
alert("Erro ao deletar mensagem"); alert(error.message || "Erro ao deletar mensagem");
} }
} }
@@ -437,22 +451,34 @@
<p class="text-xs text-base-content/50"> <p class="text-xs text-base-content/50">
{formatarDataMensagem(mensagem.enviadaEm)} {formatarDataMensagem(mensagem.enviadaEm)}
</p> </p>
{#if isMinha && !mensagem.deletada && !mensagem.agendadaPara} {#if !mensagem.deletada && !mensagem.agendadaPara}
<div class="flex gap-1"> <div class="flex gap-1">
<button {#if isMinha}
class="text-xs text-base-content/50 hover:text-primary transition-colors" <!-- Ações para minhas próprias mensagens -->
onclick={() => editarMensagem(mensagem)} <button
title="Editar mensagem" class="text-xs text-base-content/50 hover:text-primary transition-colors"
> onclick={() => editarMensagem(mensagem)}
✏️ title="Editar mensagem"
</button> >
<button ✏️
class="text-xs text-base-content/50 hover:text-error transition-colors" </button>
onclick={() => deletarMensagem(mensagem._id)} <button
title="Deletar mensagem" class="text-xs text-base-content/50 hover:text-error transition-colors"
> onclick={() => deletarMensagem(mensagem._id, false)}
🗑️ title="Deletar mensagem"
</button> >
🗑️
</button>
{:else if isAdmin?.data}
<!-- Ações para admin deletar mensagens de outros -->
<button
class="text-xs text-base-content/50 hover:text-error transition-colors"
onclick={() => deletarMensagem(mensagem._id, true)}
title="Deletar mensagem (como administrador)"
>
🗑️ Admin
</button>
{/if}
</div> </div>
{/if} {/if}
</div> </div>

View File

@@ -2,6 +2,7 @@
import { useQuery, useConvexClient } from "convex-svelte"; import { useQuery, useConvexClient } from "convex-svelte";
import { api } from "@sgse-app/backend/convex/_generated/api"; import { api } from "@sgse-app/backend/convex/_generated/api";
import { abrirConversa } from "$lib/stores/chatStore"; import { abrirConversa } from "$lib/stores/chatStore";
import { authStore } from "$lib/stores/auth.svelte";
import UserStatusBadge from "./UserStatusBadge.svelte"; import UserStatusBadge from "./UserStatusBadge.svelte";
import UserAvatar from "./UserAvatar.svelte"; import UserAvatar from "./UserAvatar.svelte";
@@ -12,24 +13,42 @@
let { onClose }: Props = $props(); let { onClose }: Props = $props();
const client = useConvexClient(); 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 searchQuery = $state("");
let selectedUsers = $state<string[]>([]); let selectedUsers = $state<string[]>([]);
let groupName = $state(""); let groupName = $state("");
let salaReuniaoName = $state("");
let loading = $state(false); let loading = $state(false);
const usuariosFiltrados = $derived(() => { const usuariosFiltrados = $derived(() => {
if (!usuarios) return []; if (!usuarios?.data) return [];
if (!searchQuery.trim()) return usuarios;
const query = searchQuery.toLowerCase(); // Filtrar o próprio usuário
return usuarios.filter((u: any) => const meuId = authStore.usuario?._id || meuPerfil?.data?._id;
u.nome.toLowerCase().includes(query) || let lista = usuarios.data.filter((u: any) => u._id !== meuId);
u.email.toLowerCase().includes(query) ||
u.matricula.toLowerCase().includes(query) // 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) { function toggleUserSelection(userId: string) {
@@ -77,26 +96,63 @@
}); });
abrirConversa(conversaId); abrirConversa(conversaId);
onClose(); onClose();
} catch (error) { } catch (error: any) {
console.error("Erro ao criar grupo:", error); 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 { } finally {
loading = false; loading = false;
} }
} }
</script> </script>
<div class="fixed inset-0 z-[100] flex items-center justify-center bg-black/50" onclick={onClose}> <div class="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm" onclick={onClose}>
<div <div
class="bg-base-100 rounded-xl shadow-2xl w-full max-w-lg max-h-[80vh] flex flex-col m-4" class="bg-base-100 rounded-2xl shadow-2xl w-full max-w-2xl max-h-[85vh] flex flex-col m-4 border border-base-300 overflow-hidden"
onclick={(e) => e.stopPropagation()} onclick={(e) => e.stopPropagation()}
style="box-shadow: 0 20px 60px -15px rgba(0, 0, 0, 0.3);"
> >
<!-- Header --> <!-- Header com gradiente -->
<div class="flex items-center justify-between px-6 py-4 border-b border-base-300"> <div class="flex items-center justify-between px-6 py-5 border-b border-base-300 relative overflow-hidden"
<h2 class="text-xl font-semibold">Nova Conversa</h2> style="background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%);">
<div class="absolute inset-0 opacity-20" style="background: radial-gradient(circle at 20% 50%, rgba(255,255,255,0.3) 0%, transparent 50%);"></div>
<h2 class="text-2xl font-bold text-white relative z-10 flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.625 12a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 0 1-2.555-.337A5.972 5.972 0 0 1 5.41 20.97a5.969 5.969 0 0 1-.474-.065 4.48 4.48 0 0 0 .978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25Z" />
</svg>
Nova Conversa
</h2>
<button <button
type="button" type="button"
class="btn btn-ghost btn-sm btn-circle" class="btn btn-ghost btn-sm btn-circle hover:bg-white/20 transition-all duration-200 relative z-10"
onclick={onClose} onclick={onClose}
aria-label="Fechar" aria-label="Fechar"
> >
@@ -104,81 +160,156 @@
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke-width="1.5" stroke-width="2"
stroke="currentColor" stroke="currentColor"
class="w-5 h-5" class="w-5 h-5 text-white"
> >
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /> <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
</div> </div>
<!-- Tabs --> <!-- Tabs melhoradas -->
<div class="tabs tabs-boxed p-4"> <div class="tabs tabs-boxed p-4 bg-base-200/50">
<button <button
type="button" type="button"
class={`tab ${activeTab === "individual" ? "tab-active" : ""}`} class={`tab flex items-center gap-2 transition-all duration-200 ${
onclick={() => (activeTab = "individual")} activeTab === "individual"
? "tab-active bg-primary text-primary-content font-semibold"
: "hover:bg-base-300"
}`}
onclick={() => {
activeTab = "individual";
selectedUsers = [];
searchQuery = "";
}}
> >
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
</svg>
Individual Individual
</button> </button>
<button <button
type="button" type="button"
class={`tab ${activeTab === "grupo" ? "tab-active" : ""}`} class={`tab flex items-center gap-2 transition-all duration-200 ${
onclick={() => (activeTab = "grupo")} activeTab === "grupo"
? "tab-active bg-primary text-primary-content font-semibold"
: "hover:bg-base-300"
}`}
onclick={() => {
activeTab = "grupo";
selectedUsers = [];
searchQuery = "";
}}
> >
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z" />
</svg>
Grupo Grupo
</button> </button>
<button
type="button"
class={`tab flex items-center gap-2 transition-all duration-200 ${
activeTab === "sala_reuniao"
? "tab-active bg-primary text-primary-content font-semibold"
: "hover:bg-base-300"
}`}
onclick={() => {
activeTab = "sala_reuniao";
selectedUsers = [];
searchQuery = "";
}}
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" />
</svg>
Sala de Reunião
</button>
</div> </div>
<!-- Content --> <!-- Content -->
<div class="flex-1 overflow-y-auto px-6"> <div class="flex-1 overflow-y-auto px-6 py-4">
{#if activeTab === "grupo"} {#if activeTab === "grupo"}
<!-- Criar Grupo --> <!-- Criar Grupo -->
<div class="mb-4"> <div class="mb-4">
<label class="label"> <label class="label pb-2">
<span class="label-text">Nome do Grupo</span> <span class="label-text font-semibold">Nome do Grupo</span>
</label> </label>
<input <input
type="text" type="text"
placeholder="Digite o nome do grupo..." placeholder="Digite o nome do grupo..."
class="input input-bordered w-full" class="input input-bordered w-full focus:input-primary transition-colors"
bind:value={groupName} bind:value={groupName}
maxlength="50" maxlength="50"
/> />
</div> </div>
<div class="mb-2"> <div class="mb-3">
<label class="label"> <label class="label pb-2">
<span class="label-text"> <span class="label-text font-semibold">
Participantes {selectedUsers.length > 0 ? `(${selectedUsers.length})` : ""} Participantes {selectedUsers.length > 0 ? `(${selectedUsers.length} selecionado${selectedUsers.length > 1 ? 's' : ''})` : ""}
</span>
</label>
</div>
{:else if activeTab === "sala_reuniao"}
<!-- Criar Sala de Reunião -->
<div class="mb-4">
<label class="label pb-2">
<span class="label-text font-semibold">Nome da Sala de Reunião</span>
<span class="label-text-alt text-primary font-medium">👑 Você será o administrador</span>
</label>
<input
type="text"
placeholder="Digite o nome da sala de reunião..."
class="input input-bordered w-full focus:input-primary transition-colors"
bind:value={salaReuniaoName}
maxlength="50"
/>
</div>
<div class="mb-3">
<label class="label pb-2">
<span class="label-text font-semibold">
Participantes {selectedUsers.length > 0 ? `(${selectedUsers.length} selecionado${selectedUsers.length > 1 ? 's' : ''})` : ""}
</span> </span>
</label> </label>
</div> </div>
{/if} {/if}
<!-- Search --> <!-- Search melhorado -->
<div class="mb-4"> <div class="mb-4 relative">
<input <input
type="text" type="text"
placeholder="Buscar usuários..." placeholder="🔍 Buscar usuários por nome, email ou matrícula..."
class="input input-bordered w-full" class="input input-bordered w-full pl-10 focus:input-primary transition-colors"
bind:value={searchQuery} bind:value={searchQuery}
/> />
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="w-5 h-5 absolute left-3 top-1/2 -translate-y-1/2 text-base-content/40"
>
<path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
</svg>
</div> </div>
<!-- Lista de usuários --> <!-- Lista de usuários -->
<div class="space-y-2"> <div class="space-y-2">
{#if usuarios && usuariosFiltrados().length > 0} {#if usuarios?.data && usuariosFiltrados().length > 0}
{#each usuariosFiltrados() as usuario (usuario._id)} {#each usuariosFiltrados() as usuario (usuario._id)}
{@const isSelected = selectedUsers.includes(usuario._id)}
<button <button
type="button" type="button"
class={`w-full text-left px-4 py-3 rounded-lg border transition-colors flex items-center gap-3 ${ class={`w-full text-left px-4 py-3 rounded-xl border-2 transition-all duration-200 flex items-center gap-3 ${
activeTab === "grupo" && selectedUsers.includes(usuario._id) isSelected
? "border-primary bg-primary/10" ? "border-primary bg-primary/10 shadow-md scale-[1.02]"
: "border-base-300 hover:bg-base-200" : "border-base-300 hover:bg-base-200 hover:border-primary/30 hover:shadow-sm"
}`} } ${loading ? "opacity-50 cursor-not-allowed" : "cursor-pointer"}`}
onclick={() => { onclick={() => {
if (loading) return;
if (activeTab === "individual") { if (activeTab === "individual") {
handleCriarIndividual(usuario._id); handleCriarIndividual(usuario._id);
} else { } else {
@@ -191,62 +322,106 @@
<div class="relative flex-shrink-0"> <div class="relative flex-shrink-0">
<UserAvatar <UserAvatar
avatar={usuario.avatar} avatar={usuario.avatar}
fotoPerfilUrl={usuario.fotoPerfil} fotoPerfilUrl={usuario.fotoPerfilUrl}
nome={usuario.nome} nome={usuario.nome}
size="sm" size="md"
/> />
<div class="absolute bottom-0 right-0"> <div class="absolute -bottom-1 -right-1">
<UserStatusBadge status={usuario.statusPresenca} size="sm" /> <UserStatusBadge status={usuario.statusPresenca || "offline"} size="sm" />
</div> </div>
</div> </div>
<!-- Info --> <!-- Info -->
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<p class="font-medium text-base-content truncate">{usuario.nome}</p> <p class="font-semibold text-base-content truncate">{usuario.nome}</p>
<p class="text-sm text-base-content/60 truncate"> <p class="text-sm text-base-content/60 truncate">
{usuario.setor || usuario.email} {usuario.setor || usuario.email || usuario.matricula || "Sem informações"}
</p> </p>
</div> </div>
<!-- Checkbox (apenas para grupo) --> <!-- Checkbox melhorado (para grupo e sala de reunião) -->
{#if activeTab === "grupo"} {#if activeTab === "grupo" || activeTab === "sala_reuniao"}
<input <div class="flex-shrink-0">
type="checkbox" <input
class="checkbox checkbox-primary" type="checkbox"
checked={selectedUsers.includes(usuario._id)} class="checkbox checkbox-primary checkbox-lg"
readonly checked={isSelected}
/> readonly
/>
</div>
{:else}
<!-- Ícone de seta para individual -->
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5 text-base-content/40">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3" />
</svg>
{/if} {/if}
</button> </button>
{/each} {/each}
{:else if !usuarios} {:else if !usuarios?.data}
<div class="flex items-center justify-center py-8"> <div class="flex flex-col items-center justify-center py-12">
<span class="loading loading-spinner loading-lg"></span> <span class="loading loading-spinner loading-lg text-primary"></span>
<p class="mt-4 text-base-content/60">Carregando usuários...</p>
</div> </div>
{:else} {:else}
<div class="text-center py-8 text-base-content/50"> <div class="flex flex-col items-center justify-center py-12 text-center">
Nenhum usuário encontrado <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-16 h-16 text-base-content/30 mb-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
</svg>
<p class="text-base-content/70 font-medium">
{searchQuery.trim() ? "Nenhum usuário encontrado" : "Nenhum usuário disponível"}
</p>
{#if searchQuery.trim()}
<p class="text-sm text-base-content/50 mt-2">Tente buscar por nome, email ou matrícula</p>
{/if}
</div> </div>
{/if} {/if}
</div> </div>
</div> </div>
<!-- Footer (apenas para grupo) --> <!-- Footer (para grupo e sala de reunião) -->
{#if activeTab === "grupo"} {#if activeTab === "grupo"}
<div class="px-6 py-4 border-t border-base-300"> <div class="px-6 py-4 border-t border-base-300 bg-base-200/50">
<button <button
type="button" type="button"
class="btn btn-primary btn-block" class="btn btn-primary btn-block btn-lg font-semibold shadow-lg hover:shadow-xl transition-all duration-200"
onclick={handleCriarGrupo} onclick={handleCriarGrupo}
disabled={loading || selectedUsers.length < 2 || !groupName.trim()} disabled={loading || selectedUsers.length < 2 || !groupName.trim()}
> >
{#if loading} {#if loading}
<span class="loading loading-spinner"></span> <span class="loading loading-spinner"></span>
Criando... Criando grupo...
{:else} {:else}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
Criar Grupo Criar Grupo
{/if} {/if}
</button> </button>
{#if selectedUsers.length < 2 && activeTab === "grupo"}
<p class="text-xs text-base-content/50 text-center mt-2">Selecione pelo menos 2 participantes</p>
{/if}
</div>
{:else if activeTab === "sala_reuniao"}
<div class="px-6 py-4 border-t border-base-300 bg-base-200/50">
<button
type="button"
class="btn btn-primary btn-block btn-lg font-semibold shadow-lg hover:shadow-xl transition-all duration-200"
onclick={handleCriarSalaReuniao}
disabled={loading || selectedUsers.length < 1 || !salaReuniaoName.trim()}
>
{#if loading}
<span class="loading loading-spinner"></span>
Criando sala...
{:else}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
Criar Sala de Reunião
{/if}
</button>
{#if selectedUsers.length < 1 && activeTab === "sala_reuniao"}
<p class="text-xs text-base-content/50 text-center mt-2">Selecione pelo menos 1 participante</p>
{/if}
</div> </div>
{/if} {/if}
</div> </div>

View File

@@ -0,0 +1,376 @@
<script lang="ts">
import { useQuery, useConvexClient } from "convex-svelte";
import { api } from "@sgse-app/backend/convex/_generated/api";
import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
import UserAvatar from "./UserAvatar.svelte";
import UserStatusBadge from "./UserStatusBadge.svelte";
interface Props {
conversaId: Id<"conversas">;
isAdmin: boolean;
onClose: () => void;
}
let { conversaId, isAdmin, onClose }: Props = $props();
const client = useConvexClient();
const conversas = useQuery(api.chat.listarConversas, {});
const todosUsuarios = useQuery(api.chat.listarTodosUsuarios, {});
let activeTab = $state<"participantes" | "adicionar">("participantes");
let searchQuery = $state("");
let loading = $state<string | null>(null);
let error = $state<string | null>(null);
const conversa = $derived(() => {
if (!conversas?.data) return null;
return conversas.data.find((c: any) => c._id === conversaId);
});
const participantes = $derived(() => {
if (!conversa() || !todosUsuarios) return [];
const participantesIds = conversa()?.participantesInfo || [];
return participantesIds
.map((p: any) => {
const usuario = todosUsuarios.find((u: any) => u._id === p._id);
return usuario ? { ...usuario, ...p } : null;
})
.filter((p: any) => p !== null);
});
const administradoresIds = $derived(() => {
return conversa()?.administradores || [];
});
const usuariosDisponiveis = $derived(() => {
if (!todosUsuarios) return [];
const participantesIds = conversa()?.participantes || [];
return todosUsuarios.filter((u: any) => !participantesIds.includes(u._id));
});
const usuariosFiltrados = $derived(() => {
if (!searchQuery.trim()) return usuariosDisponiveis();
const query = searchQuery.toLowerCase();
return usuariosDisponiveis().filter((u: any) =>
u.nome.toLowerCase().includes(query) ||
u.email.toLowerCase().includes(query) ||
u.matricula.toLowerCase().includes(query)
);
});
function isParticipanteAdmin(usuarioId: string): boolean {
return administradoresIds().includes(usuarioId as any);
}
function isCriador(usuarioId: string): boolean {
return conversa()?.criadoPor === usuarioId;
}
async function removerParticipante(participanteId: string) {
if (!confirm("Tem certeza que deseja remover este participante?")) return;
try {
loading = `remover-${participanteId}`;
error = null;
const resultado = await client.mutation(api.chat.removerParticipanteSala, {
conversaId,
participanteId: participanteId as any,
});
if (!resultado.sucesso) {
error = resultado.erro || "Erro ao remover participante";
}
} catch (err: any) {
error = err.message || "Erro ao remover participante";
} finally {
loading = null;
}
}
async function promoverAdmin(participanteId: string) {
if (!confirm("Promover este participante a administrador?")) return;
try {
loading = `promover-${participanteId}`;
error = null;
const resultado = await client.mutation(api.chat.promoverAdministrador, {
conversaId,
participanteId: participanteId as any,
});
if (!resultado.sucesso) {
error = resultado.erro || "Erro ao promover administrador";
}
} catch (err: any) {
error = err.message || "Erro ao promover administrador";
} finally {
loading = null;
}
}
async function rebaixarAdmin(participanteId: string) {
if (!confirm("Rebaixar este administrador a participante?")) return;
try {
loading = `rebaixar-${participanteId}`;
error = null;
const resultado = await client.mutation(api.chat.rebaixarAdministrador, {
conversaId,
participanteId: participanteId as any,
});
if (!resultado.sucesso) {
error = resultado.erro || "Erro ao rebaixar administrador";
}
} catch (err: any) {
error = err.message || "Erro ao rebaixar administrador";
} finally {
loading = null;
}
}
async function adicionarParticipante(usuarioId: string) {
try {
loading = `adicionar-${usuarioId}`;
error = null;
const resultado = await client.mutation(api.chat.adicionarParticipanteSala, {
conversaId,
participanteId: usuarioId as any,
});
if (!resultado.sucesso) {
error = resultado.erro || "Erro ao adicionar participante";
} else {
searchQuery = "";
}
} catch (err: any) {
error = err.message || "Erro ao adicionar participante";
} finally {
loading = null;
}
}
</script>
<div class="fixed inset-0 z-[100] flex items-center justify-center bg-black/50" onclick={onClose}>
<div
class="bg-base-100 rounded-xl shadow-2xl w-full max-w-2xl max-h-[80vh] flex flex-col m-4"
onclick={(e) => e.stopPropagation()}
>
<!-- Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-base-300">
<div>
<h2 class="text-xl font-semibold">Gerenciar Sala de Reunião</h2>
<p class="text-sm text-base-content/60">{conversa()?.nome || "Sem nome"}</p>
</div>
<button
type="button"
class="btn btn-ghost btn-sm btn-circle"
onclick={onClose}
aria-label="Fechar"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-5 h-5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Tabs -->
{#if isAdmin}
<div class="tabs tabs-boxed p-4">
<button
type="button"
class={`tab ${activeTab === "participantes" ? "tab-active" : ""}`}
onclick={() => (activeTab = "participantes")}
>
Participantes
</button>
<button
type="button"
class={`tab ${activeTab === "adicionar" ? "tab-active" : ""}`}
onclick={() => (activeTab = "adicionar")}
>
Adicionar Participante
</button>
</div>
{/if}
<!-- Error Message -->
{#if error}
<div class="mx-6 mt-2 alert alert-error">
<span>{error}</span>
<button type="button" class="btn btn-sm btn-ghost" onclick={() => (error = null)}>✕</button>
</div>
{/if}
<!-- Content -->
<div class="flex-1 overflow-y-auto px-6">
{#if activeTab === "participantes"}
<!-- Lista de Participantes -->
<div class="space-y-2 py-2">
{#if participantes().length > 0}
{#each participantes() as participante (participante._id)}
{@const ehAdmin = isParticipanteAdmin(participante._id)}
{@const ehCriador = isCriador(participante._id)}
{@const isLoading = loading?.includes(participante._id)}
<div
class="flex items-center gap-3 p-3 rounded-lg border border-base-300 hover:bg-base-200 transition-colors"
>
<!-- Avatar -->
<div class="relative flex-shrink-0">
<UserAvatar
avatar={participante.avatar}
fotoPerfilUrl={participante.fotoPerfilUrl}
nome={participante.nome}
size="sm"
/>
<div class="absolute bottom-0 right-0">
<UserStatusBadge status={participante.statusPresenca} size="sm" />
</div>
</div>
<!-- Info -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<p class="font-medium text-base-content truncate">{participante.nome}</p>
{#if ehAdmin}
<span class="badge badge-primary badge-sm">Admin</span>
{/if}
{#if ehCriador}
<span class="badge badge-secondary badge-sm">Criador</span>
{/if}
</div>
<p class="text-sm text-base-content/60 truncate">
{participante.setor || participante.email}
</p>
</div>
<!-- Ações (apenas para admins) -->
{#if isAdmin && !ehCriador}
<div class="flex items-center gap-1">
{#if ehAdmin}
<button
type="button"
class="btn btn-xs btn-ghost"
onclick={() => rebaixarAdmin(participante._id)}
disabled={isLoading}
title="Rebaixar administrador"
>
{#if isLoading && loading?.includes("rebaixar")}
<span class="loading loading-spinner loading-xs"></span>
{:else}
⬇️
{/if}
</button>
{:else}
<button
type="button"
class="btn btn-xs btn-ghost"
onclick={() => promoverAdmin(participante._id)}
disabled={isLoading}
title="Promover a administrador"
>
{#if isLoading && loading?.includes("promover")}
<span class="loading loading-spinner loading-xs"></span>
{:else}
⬆️
{/if}
</button>
{/if}
<button
type="button"
class="btn btn-xs btn-error btn-ghost"
onclick={() => removerParticipante(participante._id)}
disabled={isLoading}
title="Remover participante"
>
{#if isLoading && loading?.includes("remover")}
<span class="loading loading-spinner loading-xs"></span>
{:else}
{/if}
</button>
</div>
{/if}
</div>
{/each}
{:else}
<div class="text-center py-8 text-base-content/50">
Nenhum participante encontrado
</div>
{/if}
</div>
{:else if activeTab === "adicionar" && isAdmin}
<!-- Adicionar Participante -->
<div class="mb-4">
<input
type="text"
placeholder="Buscar usuários..."
class="input input-bordered w-full"
bind:value={searchQuery}
/>
</div>
<div class="space-y-2">
{#if usuariosFiltrados().length > 0}
{#each usuariosFiltrados() as usuario (usuario._id)}
{@const isLoading = loading?.includes(usuario._id)}
<button
type="button"
class="w-full text-left px-4 py-3 rounded-lg border border-base-300 hover:bg-base-200 transition-colors flex items-center gap-3"
onclick={() => adicionarParticipante(usuario._id)}
disabled={isLoading}
>
<!-- Avatar -->
<div class="relative flex-shrink-0">
<UserAvatar
avatar={usuario.avatar}
fotoPerfilUrl={usuario.fotoPerfilUrl}
nome={usuario.nome}
size="sm"
/>
<div class="absolute bottom-0 right-0">
<UserStatusBadge status={usuario.statusPresenca} size="sm" />
</div>
</div>
<!-- Info -->
<div class="flex-1 min-w-0">
<p class="font-medium text-base-content truncate">{usuario.nome}</p>
<p class="text-sm text-base-content/60 truncate">
{usuario.setor || usuario.email}
</p>
</div>
<!-- Botão Adicionar -->
{#if isLoading}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<span class="text-primary">+</span>
{/if}
</button>
{/each}
{:else}
<div class="text-center py-8 text-base-content/50">
{searchQuery.trim() ? "Nenhum usuário encontrado" : "Todos os usuários já são participantes"}
</div>
{/if}
</div>
{/if}
</div>
<!-- Footer -->
<div class="px-6 py-4 border-t border-base-300">
<button type="button" class="btn btn-block" onclick={onClose}>
Fechar
</button>
</div>
</div>
</div>

View File

@@ -727,12 +727,12 @@
"enviando", "enviando",
"Criando/buscando conversa...", "Criando/buscando conversa...",
); );
const conversaResult = await client.mutation( const conversaId = await client.mutation(
api.chat.criarOuBuscarConversaIndividual, api.chat.criarOuBuscarConversaIndividual,
{ outroUsuarioId: destinatario._id as Id<"usuarios"> }, { outroUsuarioId: destinatario._id as Id<"usuarios"> },
); );
if (conversaResult.conversaId) { if (conversaId) {
const mensagem = usarTemplate const mensagem = usarTemplate
? templateSelecionado?.corpo || "" ? templateSelecionado?.corpo || ""
: mensagemPersonalizada; : mensagemPersonalizada;
@@ -748,7 +748,7 @@
resultadoChat = await client.mutation( resultadoChat = await client.mutation(
api.chat.agendarMensagem, api.chat.agendarMensagem,
{ {
conversaId: conversaResult.conversaId, conversaId: conversaId,
conteudo: mensagem, conteudo: mensagem,
agendadaPara: agendadaPara, agendadaPara: agendadaPara,
}, },
@@ -773,7 +773,7 @@
"Enviando mensagem...", "Enviando mensagem...",
); );
resultadoChat = await client.mutation(api.chat.enviarMensagem, { resultadoChat = await client.mutation(api.chat.enviarMensagem, {
conversaId: conversaResult.conversaId, conversaId: conversaId,
conteudo: mensagem, conteudo: mensagem,
tipo: "texto", tipo: "texto",
permitirNotificacaoParaSiMesmo: true, permitirNotificacaoParaSiMesmo: true,
@@ -982,12 +982,12 @@
"enviando", "enviando",
"Processando...", "Processando...",
); );
const conversaResult = await client.mutation( const conversaId = await client.mutation(
api.chat.criarOuBuscarConversaIndividual, api.chat.criarOuBuscarConversaIndividual,
{ outroUsuarioId: destinatario._id as Id<"usuarios"> }, { outroUsuarioId: destinatario._id as Id<"usuarios"> },
); );
if (conversaResult.conversaId) { if (conversaId) {
// Para templates, usar corpo direto (o backend já faz substituição via email) // Para templates, usar corpo direto (o backend já faz substituição via email)
// Para mensagem personalizada, usar diretamente // Para mensagem personalizada, usar diretamente
const mensagem = usarTemplate const mensagem = usarTemplate
@@ -996,7 +996,7 @@
if (agendadaPara) { if (agendadaPara) {
await client.mutation(api.chat.agendarMensagem, { await client.mutation(api.chat.agendarMensagem, {
conversaId: conversaResult.conversaId, conversaId: conversaId,
conteudo: mensagem, conteudo: mensagem,
agendadaPara: agendadaPara, agendadaPara: agendadaPara,
}); });
@@ -1013,7 +1013,7 @@
); );
} else { } else {
await client.mutation(api.chat.enviarMensagem, { await client.mutation(api.chat.enviarMensagem, {
conversaId: conversaResult.conversaId, conversaId: conversaId,
conteudo: mensagem, conteudo: mensagem,
tipo: "texto", tipo: "texto",
permitirNotificacaoParaSiMesmo: true, permitirNotificacaoParaSiMesmo: true,

View File

@@ -48,6 +48,30 @@ async function getUsuarioAutenticado(ctx: QueryCtx | MutationCtx) {
return usuarioAtual; return usuarioAtual;
} }
/**
* Helper function para verificar se usuário é administrador de uma sala de reunião
*/
async function verificarPermissaoAdmin(
ctx: QueryCtx | MutationCtx,
conversaId: Id<"conversas">,
usuarioId: Id<"usuarios">
): Promise<boolean> {
const conversa = await ctx.db.get(conversaId);
if (!conversa) return false;
// Verificar se é sala de reunião
if (conversa.tipo !== "sala_reuniao") return false;
// Verificar se tem array de administradores
if (!conversa.administradores || conversa.administradores.length === 0) {
// Se não tem administradores definidos, o criador é admin por padrão
return conversa.criadoPor === usuarioId;
}
// Verificar se está na lista de administradores
return conversa.administradores.includes(usuarioId);
}
// ========== MUTATIONS ========== // ========== MUTATIONS ==========
/** /**
@@ -55,7 +79,7 @@ async function getUsuarioAutenticado(ctx: QueryCtx | MutationCtx) {
*/ */
export const criarConversa = mutation({ export const criarConversa = mutation({
args: { args: {
tipo: v.union(v.literal("individual"), v.literal("grupo")), tipo: v.union(v.literal("individual"), v.literal("grupo"), v.literal("sala_reuniao")),
participantes: v.array(v.id("usuarios")), participantes: v.array(v.id("usuarios")),
nome: v.optional(v.string()), nome: v.optional(v.string()),
avatar: v.optional(v.string()), avatar: v.optional(v.string()),
@@ -86,27 +110,38 @@ export const criarConversa = mutation({
} }
} }
// Criar nova conversa // Preparar dados da conversa
const conversaId = await ctx.db.insert("conversas", { const dadosConversa: any = {
tipo: args.tipo, tipo: args.tipo,
nome: args.nome, nome: args.nome,
avatar: args.avatar, avatar: args.avatar,
participantes: args.participantes, participantes: args.participantes,
criadoPor: usuarioAtual._id, criadoPor: usuarioAtual._id,
criadoEm: Date.now(), criadoEm: Date.now(),
}); };
// Se for sala de reunião, adicionar administradores (criador sempre é admin)
if (args.tipo === "sala_reuniao") {
dadosConversa.administradores = [usuarioAtual._id];
}
// Criar nova conversa
const conversaId = await ctx.db.insert("conversas", dadosConversa);
// Criar notificações para outros participantes // Criar notificações para outros participantes
if (args.tipo === "grupo") { if (args.tipo === "grupo" || args.tipo === "sala_reuniao") {
const tipoNotificacao = args.tipo === "sala_reuniao" ? "adicionado_grupo" : "adicionado_grupo";
const tipoTexto = args.tipo === "sala_reuniao" ? "sala de reunião" : "grupo";
for (const participanteId of args.participantes) { for (const participanteId of args.participantes) {
if (participanteId !== usuarioAtual._id) { if (participanteId !== usuarioAtual._id) {
await ctx.db.insert("notificacoes", { await ctx.db.insert("notificacoes", {
usuarioId: participanteId, usuarioId: participanteId,
tipo: "adicionado_grupo", tipo: tipoNotificacao,
conversaId, conversaId,
remetenteId: usuarioAtual._id, remetenteId: usuarioAtual._id,
titulo: "Adicionado a grupo", titulo: args.tipo === "sala_reuniao" ? "Adicionado a sala de reunião" : "Adicionado a grupo",
descricao: `Você foi adicionado ao grupo "${ descricao: `Você foi adicionado à ${tipoTexto} "${
args.nome || "Sem nome" args.nome || "Sem nome"
}" por ${usuarioAtual.nome}`, }" por ${usuarioAtual.nome}`,
lida: false, lida: false,
@@ -120,6 +155,69 @@ export const criarConversa = mutation({
}, },
}); });
/**
* Cria uma nova sala de reunião (wrapper específico para facilitar uso)
*/
export const criarSalaReuniao = mutation({
args: {
nome: v.string(),
participantes: v.array(v.id("usuarios")),
avatar: v.optional(v.string()),
},
handler: async (ctx, args) => {
const usuarioAtual = await getUsuarioAutenticado(ctx);
if (!usuarioAtual) throw new Error("Não autenticado");
// Validar nome
if (!args.nome || args.nome.trim().length === 0) {
throw new Error("O nome da sala de reunião é obrigatório");
}
// Validar participantes
const participantesUnicos = [...new Set(args.participantes)];
if (!participantesUnicos.includes(usuarioAtual._id)) {
participantesUnicos.push(usuarioAtual._id);
}
// Preparar dados da conversa
const dadosConversa: any = {
tipo: "sala_reuniao" as const,
nome: args.nome.trim(),
avatar: args.avatar,
participantes: participantesUnicos,
criadoPor: usuarioAtual._id,
criadoEm: Date.now(),
administradores: [usuarioAtual._id], // Criador sempre é admin
};
// Criar nova conversa
const conversaId = await ctx.db.insert("conversas", dadosConversa);
// Criar notificações para outros participantes
const tipoNotificacao = "adicionado_grupo";
const tipoTexto = "sala de reunião";
for (const participanteId of participantesUnicos) {
if (participanteId !== usuarioAtual._id) {
await ctx.db.insert("notificacoes", {
usuarioId: participanteId,
tipo: tipoNotificacao,
conversaId,
remetenteId: usuarioAtual._id,
titulo: "Adicionado a sala de reunião",
descricao: `Você foi adicionado à ${tipoTexto} "${
args.nome || "Sem nome"
}" por ${usuarioAtual.nome}`,
lida: false,
criadaEm: Date.now(),
});
}
}
return conversaId;
},
});
/** /**
* Cria ou busca uma conversa individual com outro usuário * Cria ou busca uma conversa individual com outro usuário
*/ */
@@ -840,7 +938,10 @@ export const deletarMensagem = mutation({
throw new Error("Mensagem inválida"); throw new Error("Mensagem inválida");
} }
if (mensagem.remetenteId !== usuarioAtual._id) { // Verificar se é admin de sala de reunião ou se é o próprio remetente
const isAdmin = await verificarPermissaoAdmin(ctx, mensagem.conversaId, usuarioAtual._id);
if (mensagem.remetenteId !== usuarioAtual._id && !isAdmin) {
throw new Error("Você só pode deletar suas próprias mensagens"); throw new Error("Você só pode deletar suas próprias mensagens");
} }
@@ -853,8 +954,370 @@ export const deletarMensagem = mutation({
}, },
}); });
/**
* Deleta uma mensagem como administrador (com notificação ao remetente)
*/
export const deletarMensagemComoAdmin = mutation({
args: {
mensagemId: v.id("mensagens"),
},
returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }),
handler: async (ctx, args) => {
const usuarioAtual = await getUsuarioAutenticado(ctx);
if (!usuarioAtual) {
return { sucesso: false, erro: "Não autenticado" };
}
const mensagem = await ctx.db.get(args.mensagemId);
if (!mensagem) {
return { sucesso: false, erro: "Mensagem não encontrada" };
}
// SEGURANÇA: Verificar se a mensagem pertence a uma conversa onde o usuário é participante
const conversa = await ctx.db.get(mensagem.conversaId);
if (!conversa || !conversa.participantes.includes(usuarioAtual._id)) {
return { sucesso: false, erro: "Você não tem acesso a esta mensagem" };
}
// SEGURANÇA: Verificar se o remetente da mensagem é participante da conversa
if (!conversa.participantes.includes(mensagem.remetenteId)) {
return { sucesso: false, erro: "Mensagem inválida" };
}
// Verificar se usuário é administrador da sala
const isAdmin = await verificarPermissaoAdmin(ctx, mensagem.conversaId, usuarioAtual._id);
if (!isAdmin) {
return { sucesso: false, erro: "Apenas administradores podem deletar mensagens de outros usuários" };
}
// Não permitir deletar mensagem já deletada
if (mensagem.deletada) {
return { sucesso: false, erro: "Mensagem já foi deletada" };
}
// Deletar mensagem
await ctx.db.patch(args.mensagemId, {
deletada: true,
conteudo: "Mensagem deletada por administrador",
});
// Criar notificação para o remetente original (se não for o próprio admin)
if (mensagem.remetenteId !== usuarioAtual._id) {
const remetente = await ctx.db.get(mensagem.remetenteId);
if (remetente) {
await ctx.db.insert("notificacoes", {
usuarioId: mensagem.remetenteId,
tipo: "nova_mensagem",
conversaId: mensagem.conversaId,
mensagemId: args.mensagemId,
remetenteId: usuarioAtual._id,
titulo: "Mensagem deletada",
descricao: `Sua mensagem foi deletada por um administrador da sala "${conversa.nome || "Sem nome"}"`,
lida: false,
criadaEm: Date.now(),
});
}
}
return { sucesso: true };
},
});
/**
* Adiciona um participante à sala de reunião (apenas administradores)
*/
export const adicionarParticipanteSala = mutation({
args: {
conversaId: v.id("conversas"),
participanteId: v.id("usuarios"),
},
returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }),
handler: async (ctx, args) => {
const usuarioAtual = await getUsuarioAutenticado(ctx);
if (!usuarioAtual) {
return { sucesso: false, erro: "Não autenticado" };
}
const conversa = await ctx.db.get(args.conversaId);
if (!conversa) {
return { sucesso: false, erro: "Sala de reunião não encontrada" };
}
// Verificar se é sala de reunião
if (conversa.tipo !== "sala_reuniao") {
return { sucesso: false, erro: "Esta funcionalidade é apenas para salas de reunião" };
}
// Verificar se usuário é administrador
const isAdmin = await verificarPermissaoAdmin(ctx, args.conversaId, usuarioAtual._id);
if (!isAdmin) {
return { sucesso: false, erro: "Apenas administradores podem adicionar participantes" };
}
// Verificar se participante já está na sala
if (conversa.participantes.includes(args.participanteId)) {
return { sucesso: false, erro: "Usuário já é participante desta sala" };
}
// Verificar se participante existe
const participante = await ctx.db.get(args.participanteId);
if (!participante) {
return { sucesso: false, erro: "Usuário não encontrado" };
}
// Adicionar participante
const novosParticipantes = [...conversa.participantes, args.participanteId];
await ctx.db.patch(args.conversaId, {
participantes: novosParticipantes,
});
// Criar notificação para o novo participante
await ctx.db.insert("notificacoes", {
usuarioId: args.participanteId,
tipo: "adicionado_grupo",
conversaId: args.conversaId,
remetenteId: usuarioAtual._id,
titulo: "Adicionado a sala de reunião",
descricao: `Você foi adicionado à sala de reunião "${conversa.nome || "Sem nome"}" por ${usuarioAtual.nome}`,
lida: false,
criadaEm: Date.now(),
});
return { sucesso: true };
},
});
/**
* Remove um participante da sala de reunião (apenas administradores, não pode remover outros admins)
*/
export const removerParticipanteSala = mutation({
args: {
conversaId: v.id("conversas"),
participanteId: v.id("usuarios"),
},
returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }),
handler: async (ctx, args) => {
const usuarioAtual = await getUsuarioAutenticado(ctx);
if (!usuarioAtual) {
return { sucesso: false, erro: "Não autenticado" };
}
const conversa = await ctx.db.get(args.conversaId);
if (!conversa) {
return { sucesso: false, erro: "Sala de reunião não encontrada" };
}
// Verificar se é sala de reunião
if (conversa.tipo !== "sala_reuniao") {
return { sucesso: false, erro: "Esta funcionalidade é apenas para salas de reunião" };
}
// Verificar se usuário é administrador
const isAdmin = await verificarPermissaoAdmin(ctx, args.conversaId, usuarioAtual._id);
if (!isAdmin) {
return { sucesso: false, erro: "Apenas administradores podem remover participantes" };
}
// Verificar se participante está na sala
if (!conversa.participantes.includes(args.participanteId)) {
return { sucesso: false, erro: "Usuário não é participante desta sala" };
}
// Verificar se está tentando remover outro administrador
const isParticipanteAdmin = await verificarPermissaoAdmin(ctx, args.conversaId, args.participanteId);
if (isParticipanteAdmin) {
return { sucesso: false, erro: "Não é possível remover outros administradores" };
}
// Remover participante
const novosParticipantes = conversa.participantes.filter((p) => p !== args.participanteId);
await ctx.db.patch(args.conversaId, {
participantes: novosParticipantes,
});
// Criar notificação para o participante removido
const participanteRemovido = await ctx.db.get(args.participanteId);
if (participanteRemovido) {
await ctx.db.insert("notificacoes", {
usuarioId: args.participanteId,
tipo: "nova_mensagem",
conversaId: args.conversaId,
remetenteId: usuarioAtual._id,
titulo: "Removido da sala de reunião",
descricao: `Você foi removido da sala de reunião "${conversa.nome || "Sem nome"}" por ${usuarioAtual.nome}`,
lida: false,
criadaEm: Date.now(),
});
}
return { sucesso: true };
},
});
/**
* Promove um participante a administrador (apenas administradores)
*/
export const promoverAdministrador = mutation({
args: {
conversaId: v.id("conversas"),
participanteId: v.id("usuarios"),
},
returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }),
handler: async (ctx, args) => {
const usuarioAtual = await getUsuarioAutenticado(ctx);
if (!usuarioAtual) {
return { sucesso: false, erro: "Não autenticado" };
}
const conversa = await ctx.db.get(args.conversaId);
if (!conversa) {
return { sucesso: false, erro: "Sala de reunião não encontrada" };
}
// Verificar se é sala de reunião
if (conversa.tipo !== "sala_reuniao") {
return { sucesso: false, erro: "Esta funcionalidade é apenas para salas de reunião" };
}
// Verificar se usuário é administrador
const isAdmin = await verificarPermissaoAdmin(ctx, args.conversaId, usuarioAtual._id);
if (!isAdmin) {
return { sucesso: false, erro: "Apenas administradores podem promover outros administradores" };
}
// Verificar se participante está na sala
if (!conversa.participantes.includes(args.participanteId)) {
return { sucesso: false, erro: "Usuário não é participante desta sala" };
}
// Verificar se já é administrador
const jaEhAdmin = await verificarPermissaoAdmin(ctx, args.conversaId, args.participanteId);
if (jaEhAdmin) {
return { sucesso: false, erro: "Usuário já é administrador desta sala" };
}
// Obter lista atual de administradores ou criar nova
const administradoresAtuais = conversa.administradores || [];
// Se não está na lista, adicionar
if (!administradoresAtuais.includes(args.participanteId)) {
const novosAdministradores = [...administradoresAtuais, args.participanteId];
await ctx.db.patch(args.conversaId, {
administradores: novosAdministradores,
});
// Criar notificação para o novo administrador
const novoAdmin = await ctx.db.get(args.participanteId);
if (novoAdmin) {
await ctx.db.insert("notificacoes", {
usuarioId: args.participanteId,
tipo: "nova_mensagem",
conversaId: args.conversaId,
remetenteId: usuarioAtual._id,
titulo: "Promovido a administrador",
descricao: `Você foi promovido a administrador da sala de reunião "${conversa.nome || "Sem nome"}" por ${usuarioAtual.nome}`,
lida: false,
criadaEm: Date.now(),
});
}
}
return { sucesso: true };
},
});
/**
* Rebaixa um administrador a participante (apenas administradores, não pode rebaixar a si mesmo)
*/
export const rebaixarAdministrador = mutation({
args: {
conversaId: v.id("conversas"),
participanteId: v.id("usuarios"),
},
returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }),
handler: async (ctx, args) => {
const usuarioAtual = await getUsuarioAutenticado(ctx);
if (!usuarioAtual) {
return { sucesso: false, erro: "Não autenticado" };
}
const conversa = await ctx.db.get(args.conversaId);
if (!conversa) {
return { sucesso: false, erro: "Sala de reunião não encontrada" };
}
// Verificar se é sala de reunião
if (conversa.tipo !== "sala_reuniao") {
return { sucesso: false, erro: "Esta funcionalidade é apenas para salas de reunião" };
}
// Verificar se usuário é administrador
const isAdmin = await verificarPermissaoAdmin(ctx, args.conversaId, usuarioAtual._id);
if (!isAdmin) {
return { sucesso: false, erro: "Apenas administradores podem rebaixar outros administradores" };
}
// Não permitir rebaixar a si mesmo
if (args.participanteId === usuarioAtual._id) {
return { sucesso: false, erro: "Você não pode rebaixar a si mesmo" };
}
// Verificar se é administrador
const isParticipanteAdmin = await verificarPermissaoAdmin(ctx, args.conversaId, args.participanteId);
if (!isParticipanteAdmin) {
return { sucesso: false, erro: "Usuário não é administrador desta sala" };
}
// Não permitir rebaixar o criador da sala
if (conversa.criadoPor === args.participanteId) {
return { sucesso: false, erro: "Não é possível rebaixar o criador da sala" };
}
// Remover da lista de administradores
const administradoresAtuais = conversa.administradores || [];
const novosAdministradores = administradoresAtuais.filter((adminId) => adminId !== args.participanteId);
await ctx.db.patch(args.conversaId, {
administradores: novosAdministradores.length > 0 ? novosAdministradores : undefined,
});
// Criar notificação para o administrador rebaixado
const adminRebaixado = await ctx.db.get(args.participanteId);
if (adminRebaixado) {
await ctx.db.insert("notificacoes", {
usuarioId: args.participanteId,
tipo: "nova_mensagem",
conversaId: args.conversaId,
remetenteId: usuarioAtual._id,
titulo: "Rebaixado de administrador",
descricao: `Você foi rebaixado de administrador da sala de reunião "${conversa.nome || "Sem nome"}" por ${usuarioAtual.nome}`,
lida: false,
criadaEm: Date.now(),
});
}
return { sucesso: true };
},
});
// ========== QUERIES ========== // ========== QUERIES ==========
/**
* Verifica se o usuário atual é administrador de uma sala de reunião
*/
export const verificarSeEhAdmin = query({
args: {
conversaId: v.id("conversas"),
},
returns: v.boolean(),
handler: async (ctx, args) => {
const usuarioAtual = await getUsuarioAutenticado(ctx);
if (!usuarioAtual) return false;
return await verificarPermissaoAdmin(ctx, args.conversaId, usuarioAtual._id);
},
});
/** /**
* Lista todas as conversas do usuário logado * Lista todas as conversas do usuário logado
* SEGURANÇA: Usuário só vê conversas onde é participante * SEGURANÇA: Usuário só vê conversas onde é participante
@@ -947,11 +1410,36 @@ export const listarConversas = query({
).length; ).length;
} }
// Verificar se usuário é administrador (apenas para salas de reunião)
const isAdmin = conversa.tipo === "sala_reuniao"
? await verificarPermissaoAdmin(ctx, conversa._id, usuarioAtual._id)
: false;
// Enriquecer participantes com fotoPerfilUrl (para grupos e salas)
const participantesInfo = await Promise.all(
participantes
.filter((p) => p !== null)
.map(async (participante) => {
if (!participante) return null;
let fotoPerfilUrl = null;
if (participante.fotoPerfil) {
fotoPerfilUrl = await ctx.storage.getUrl(participante.fotoPerfil);
}
return {
...participante,
fotoPerfilUrl,
};
})
);
return { return {
...conversa, ...conversa,
outroUsuario, outroUsuario,
participantesInfo: participantes.filter((p) => p !== null), participantesInfo: participantesInfo.filter((p) => p !== null),
naoLidas, naoLidas,
isAdmin, // Adicionar flag de admin
}; };
}) })
); );

View File

@@ -619,10 +619,11 @@ export default defineSchema({
// Sistema de Chat // Sistema de Chat
conversas: defineTable({ conversas: defineTable({
tipo: v.union(v.literal("individual"), v.literal("grupo")), tipo: v.union(v.literal("individual"), v.literal("grupo"), v.literal("sala_reuniao")),
nome: v.optional(v.string()), // nome do grupo nome: v.optional(v.string()), // nome do grupo/sala
avatar: v.optional(v.string()), // avatar do grupo avatar: v.optional(v.string()), // avatar do grupo/sala
participantes: v.array(v.id("usuarios")), // IDs dos participantes participantes: v.array(v.id("usuarios")), // IDs dos participantes
administradores: v.optional(v.array(v.id("usuarios"))), // IDs dos administradores (apenas para sala_reuniao)
ultimaMensagem: v.optional(v.string()), ultimaMensagem: v.optional(v.string()),
ultimaMensagemTimestamp: v.optional(v.number()), ultimaMensagemTimestamp: v.optional(v.number()),
criadoPor: v.id("usuarios"), criadoPor: v.id("usuarios"),