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:
@@ -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;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-full">
|
||||
@@ -161,104 +199,214 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Título da Lista -->
|
||||
<div class="p-4 border-b border-base-300 bg-base-200">
|
||||
<h3 class="font-semibold text-sm text-base-content/70 uppercase tracking-wide">
|
||||
Usuários do Sistema ({usuariosFiltrados.length})
|
||||
</h3>
|
||||
</div>
|
||||
<!-- Tabs e Título -->
|
||||
<div class="border-b border-base-300 bg-base-200">
|
||||
<!-- Tabs -->
|
||||
<div class="tabs tabs-boxed p-2">
|
||||
<button
|
||||
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 -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
{#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">
|
||||
<!-- Botão Nova Conversa -->
|
||||
<div class="px-4 pb-2 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm"
|
||||
onclick={() => (showNewConversationModal = true)}
|
||||
title="Nova conversa (grupo ou sala de reunião)"
|
||||
aria-label="Nova conversa"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
class="w-16 h-16 text-base-content/30 mb-4"
|
||||
class="w-4 h-4 mr-1"
|
||||
>
|
||||
<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"
|
||||
/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
<p class="text-base-content/70">Nenhum usuário encontrado</p>
|
||||
</div>
|
||||
Nova Conversa
|
||||
</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}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Modal de Nova Conversa -->
|
||||
{#if showNewConversationModal}
|
||||
<NewConversationModal onClose={() => (showNewConversationModal = false)} />
|
||||
{/if}
|
||||
|
||||
@@ -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<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
|
||||
$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 @@
|
||||
</svg>
|
||||
</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 -->
|
||||
<button
|
||||
type="button"
|
||||
@@ -363,12 +489,59 @@
|
||||
</div>
|
||||
|
||||
<!-- Conteúdo -->
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<div class="flex-1 overflow-hidden relative">
|
||||
{#if !activeConversation}
|
||||
<ChatList />
|
||||
{:else}
|
||||
<ChatWindow conversaId={activeConversation} />
|
||||
{/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>
|
||||
{/if}
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
import UserStatusBadge from "./UserStatusBadge.svelte";
|
||||
import UserAvatar from "./UserAvatar.svelte";
|
||||
import ScheduleMessageModal from "./ScheduleMessageModal.svelte";
|
||||
import SalaReuniaoManager from "./SalaReuniaoManager.svelte";
|
||||
import { getAvatarUrl } from "$lib/utils/avatarGenerator";
|
||||
|
||||
interface Props {
|
||||
conversaId: string;
|
||||
@@ -16,8 +18,10 @@
|
||||
let { conversaId }: Props = $props();
|
||||
|
||||
let showScheduleModal = $state(false);
|
||||
let showSalaManager = $state(false);
|
||||
|
||||
const conversas = useQuery(api.chat.listarConversas, {});
|
||||
const isAdmin = useQuery(api.chat.verificarSeEhAdmin, { conversaId: conversaId as any });
|
||||
|
||||
const conversa = $derived(() => {
|
||||
console.log("🔍 [ChatWindow] Buscando conversa ID:", conversaId);
|
||||
@@ -36,8 +40,8 @@
|
||||
function getNomeConversa(): string {
|
||||
const c = conversa();
|
||||
if (!c) return "Carregando...";
|
||||
if (c.tipo === "grupo") {
|
||||
return c.nome || "Grupo sem nome";
|
||||
if (c.tipo === "grupo" || c.tipo === "sala_reuniao") {
|
||||
return c.nome || (c.tipo === "sala_reuniao" ? "Sala sem nome" : "Grupo sem nome");
|
||||
}
|
||||
return c.outroUsuario?.nome || "Usuário";
|
||||
}
|
||||
@@ -135,11 +139,64 @@
|
||||
? "Externo"
|
||||
: "Offline"}
|
||||
</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}
|
||||
</div>
|
||||
|
||||
<!-- Botões de ação -->
|
||||
<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 -->
|
||||
<button
|
||||
type="button"
|
||||
@@ -186,3 +243,12 @@
|
||||
/>
|
||||
{/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}
|
||||
|
||||
|
||||
@@ -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 @@
|
||||
<p class="text-xs text-base-content/50">
|
||||
{formatarDataMensagem(mensagem.enviadaEm)}
|
||||
</p>
|
||||
{#if isMinha && !mensagem.deletada && !mensagem.agendadaPara}
|
||||
{#if !mensagem.deletada && !mensagem.agendadaPara}
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
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"
|
||||
onclick={() => deletarMensagem(mensagem._id)}
|
||||
title="Deletar mensagem"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
{#if isMinha}
|
||||
<!-- Ações para minhas próprias mensagens -->
|
||||
<button
|
||||
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"
|
||||
onclick={() => deletarMensagem(mensagem._id, false)}
|
||||
title="Deletar mensagem"
|
||||
>
|
||||
🗑️
|
||||
</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>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -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<string[]>([]);
|
||||
let groupName = $state("");
|
||||
let salaReuniaoName = $state("");
|
||||
let loading = $state(false);
|
||||
|
||||
const usuariosFiltrados = $derived(() => {
|
||||
if (!usuarios) return [];
|
||||
if (!searchQuery.trim()) return usuarios;
|
||||
if (!usuarios?.data) return [];
|
||||
|
||||
const query = searchQuery.toLowerCase();
|
||||
return usuarios.filter((u: any) =>
|
||||
u.nome.toLowerCase().includes(query) ||
|
||||
u.email.toLowerCase().includes(query) ||
|
||||
u.matricula.toLowerCase().includes(query)
|
||||
);
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
</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
|
||||
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()}
|
||||
style="box-shadow: 0 20px 60px -15px rgba(0, 0, 0, 0.3);"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-base-300">
|
||||
<h2 class="text-xl font-semibold">Nova Conversa</h2>
|
||||
<!-- Header com gradiente -->
|
||||
<div class="flex items-center justify-between px-6 py-5 border-b border-base-300 relative overflow-hidden"
|
||||
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
|
||||
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}
|
||||
aria-label="Fechar"
|
||||
>
|
||||
@@ -104,81 +160,156 @@
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke-width="2"
|
||||
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" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="tabs tabs-boxed p-4">
|
||||
<!-- Tabs melhoradas -->
|
||||
<div class="tabs tabs-boxed p-4 bg-base-200/50">
|
||||
<button
|
||||
type="button"
|
||||
class={`tab ${activeTab === "individual" ? "tab-active" : ""}`}
|
||||
onclick={() => (activeTab = "individual")}
|
||||
class={`tab flex items-center gap-2 transition-all duration-200 ${
|
||||
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
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={`tab ${activeTab === "grupo" ? "tab-active" : ""}`}
|
||||
onclick={() => (activeTab = "grupo")}
|
||||
class={`tab flex items-center gap-2 transition-all duration-200 ${
|
||||
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
|
||||
</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>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 overflow-y-auto px-6">
|
||||
<div class="flex-1 overflow-y-auto px-6 py-4">
|
||||
{#if activeTab === "grupo"}
|
||||
<!-- Criar Grupo -->
|
||||
<div class="mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text">Nome do Grupo</span>
|
||||
<label class="label pb-2">
|
||||
<span class="label-text font-semibold">Nome do Grupo</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
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}
|
||||
maxlength="50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<label class="label">
|
||||
<span class="label-text">
|
||||
Participantes {selectedUsers.length > 0 ? `(${selectedUsers.length})` : ""}
|
||||
<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>
|
||||
</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>
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Search -->
|
||||
<div class="mb-4">
|
||||
<!-- Search melhorado -->
|
||||
<div class="mb-4 relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar usuários..."
|
||||
class="input input-bordered w-full"
|
||||
placeholder="🔍 Buscar usuários por nome, email ou matrícula..."
|
||||
class="input input-bordered w-full pl-10 focus:input-primary transition-colors"
|
||||
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>
|
||||
|
||||
<!-- Lista de usuários -->
|
||||
<div class="space-y-2">
|
||||
{#if usuarios && usuariosFiltrados().length > 0}
|
||||
{#if usuarios?.data && usuariosFiltrados().length > 0}
|
||||
{#each usuariosFiltrados() as usuario (usuario._id)}
|
||||
{@const isSelected = selectedUsers.includes(usuario._id)}
|
||||
<button
|
||||
type="button"
|
||||
class={`w-full text-left px-4 py-3 rounded-lg border transition-colors flex items-center gap-3 ${
|
||||
activeTab === "grupo" && selectedUsers.includes(usuario._id)
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-base-300 hover:bg-base-200"
|
||||
}`}
|
||||
class={`w-full text-left px-4 py-3 rounded-xl border-2 transition-all duration-200 flex items-center gap-3 ${
|
||||
isSelected
|
||||
? "border-primary bg-primary/10 shadow-md scale-[1.02]"
|
||||
: "border-base-300 hover:bg-base-200 hover:border-primary/30 hover:shadow-sm"
|
||||
} ${loading ? "opacity-50 cursor-not-allowed" : "cursor-pointer"}`}
|
||||
onclick={() => {
|
||||
if (loading) return;
|
||||
if (activeTab === "individual") {
|
||||
handleCriarIndividual(usuario._id);
|
||||
} else {
|
||||
@@ -191,62 +322,106 @@
|
||||
<div class="relative flex-shrink-0">
|
||||
<UserAvatar
|
||||
avatar={usuario.avatar}
|
||||
fotoPerfilUrl={usuario.fotoPerfil}
|
||||
fotoPerfilUrl={usuario.fotoPerfilUrl}
|
||||
nome={usuario.nome}
|
||||
size="sm"
|
||||
size="md"
|
||||
/>
|
||||
<div class="absolute bottom-0 right-0">
|
||||
<UserStatusBadge status={usuario.statusPresenca} size="sm" />
|
||||
<div class="absolute -bottom-1 -right-1">
|
||||
<UserStatusBadge status={usuario.statusPresenca || "offline"} 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="font-semibold text-base-content truncate">{usuario.nome}</p>
|
||||
<p class="text-sm text-base-content/60 truncate">
|
||||
{usuario.setor || usuario.email}
|
||||
{usuario.setor || usuario.email || usuario.matricula || "Sem informações"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Checkbox (apenas para grupo) -->
|
||||
{#if activeTab === "grupo"}
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-primary"
|
||||
checked={selectedUsers.includes(usuario._id)}
|
||||
readonly
|
||||
/>
|
||||
<!-- Checkbox melhorado (para grupo e sala de reunião) -->
|
||||
{#if activeTab === "grupo" || activeTab === "sala_reuniao"}
|
||||
<div class="flex-shrink-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-primary checkbox-lg"
|
||||
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}
|
||||
</button>
|
||||
{/each}
|
||||
{:else if !usuarios}
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
{:else if !usuarios?.data}
|
||||
<div class="flex flex-col items-center justify-center py-12">
|
||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||
<p class="mt-4 text-base-content/60">Carregando usuários...</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-center py-8 text-base-content/50">
|
||||
Nenhum usuário encontrado
|
||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||
<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>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer (apenas para grupo) -->
|
||||
<!-- Footer (para grupo e sala de reunião) -->
|
||||
{#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
|
||||
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}
|
||||
disabled={loading || selectedUsers.length < 2 || !groupName.trim()}
|
||||
>
|
||||
{#if loading}
|
||||
<span class="loading loading-spinner"></span>
|
||||
Criando...
|
||||
Criando grupo...
|
||||
{: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
|
||||
{/if}
|
||||
</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>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
376
apps/web/src/lib/components/chat/SalaReuniaoManager.svelte
Normal file
376
apps/web/src/lib/components/chat/SalaReuniaoManager.svelte
Normal 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>
|
||||
|
||||
@@ -727,12 +727,12 @@
|
||||
"enviando",
|
||||
"Criando/buscando conversa...",
|
||||
);
|
||||
const conversaResult = await client.mutation(
|
||||
const conversaId = await client.mutation(
|
||||
api.chat.criarOuBuscarConversaIndividual,
|
||||
{ outroUsuarioId: destinatario._id as Id<"usuarios"> },
|
||||
);
|
||||
|
||||
if (conversaResult.conversaId) {
|
||||
if (conversaId) {
|
||||
const mensagem = usarTemplate
|
||||
? templateSelecionado?.corpo || ""
|
||||
: mensagemPersonalizada;
|
||||
@@ -748,7 +748,7 @@
|
||||
resultadoChat = await client.mutation(
|
||||
api.chat.agendarMensagem,
|
||||
{
|
||||
conversaId: conversaResult.conversaId,
|
||||
conversaId: conversaId,
|
||||
conteudo: mensagem,
|
||||
agendadaPara: agendadaPara,
|
||||
},
|
||||
@@ -773,7 +773,7 @@
|
||||
"Enviando mensagem...",
|
||||
);
|
||||
resultadoChat = await client.mutation(api.chat.enviarMensagem, {
|
||||
conversaId: conversaResult.conversaId,
|
||||
conversaId: conversaId,
|
||||
conteudo: mensagem,
|
||||
tipo: "texto",
|
||||
permitirNotificacaoParaSiMesmo: true,
|
||||
@@ -982,12 +982,12 @@
|
||||
"enviando",
|
||||
"Processando...",
|
||||
);
|
||||
const conversaResult = await client.mutation(
|
||||
const conversaId = await client.mutation(
|
||||
api.chat.criarOuBuscarConversaIndividual,
|
||||
{ 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 mensagem personalizada, usar diretamente
|
||||
const mensagem = usarTemplate
|
||||
@@ -996,7 +996,7 @@
|
||||
|
||||
if (agendadaPara) {
|
||||
await client.mutation(api.chat.agendarMensagem, {
|
||||
conversaId: conversaResult.conversaId,
|
||||
conversaId: conversaId,
|
||||
conteudo: mensagem,
|
||||
agendadaPara: agendadaPara,
|
||||
});
|
||||
@@ -1013,7 +1013,7 @@
|
||||
);
|
||||
} else {
|
||||
await client.mutation(api.chat.enviarMensagem, {
|
||||
conversaId: conversaResult.conversaId,
|
||||
conversaId: conversaId,
|
||||
conteudo: mensagem,
|
||||
tipo: "texto",
|
||||
permitirNotificacaoParaSiMesmo: true,
|
||||
|
||||
@@ -48,6 +48,30 @@ async function getUsuarioAutenticado(ctx: QueryCtx | MutationCtx) {
|
||||
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 ==========
|
||||
|
||||
/**
|
||||
@@ -55,7 +79,7 @@ async function getUsuarioAutenticado(ctx: QueryCtx | MutationCtx) {
|
||||
*/
|
||||
export const criarConversa = mutation({
|
||||
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")),
|
||||
nome: v.optional(v.string()),
|
||||
avatar: v.optional(v.string()),
|
||||
@@ -86,27 +110,38 @@ export const criarConversa = mutation({
|
||||
}
|
||||
}
|
||||
|
||||
// Criar nova conversa
|
||||
const conversaId = await ctx.db.insert("conversas", {
|
||||
// Preparar dados da conversa
|
||||
const dadosConversa: any = {
|
||||
tipo: args.tipo,
|
||||
nome: args.nome,
|
||||
avatar: args.avatar,
|
||||
participantes: args.participantes,
|
||||
criadoPor: usuarioAtual._id,
|
||||
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
|
||||
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) {
|
||||
if (participanteId !== usuarioAtual._id) {
|
||||
await ctx.db.insert("notificacoes", {
|
||||
usuarioId: participanteId,
|
||||
tipo: "adicionado_grupo",
|
||||
tipo: tipoNotificacao,
|
||||
conversaId,
|
||||
remetenteId: usuarioAtual._id,
|
||||
titulo: "Adicionado a grupo",
|
||||
descricao: `Você foi adicionado ao grupo "${
|
||||
titulo: args.tipo === "sala_reuniao" ? "Adicionado a sala de reunião" : "Adicionado a grupo",
|
||||
descricao: `Você foi adicionado à ${tipoTexto} "${
|
||||
args.nome || "Sem nome"
|
||||
}" por ${usuarioAtual.nome}`,
|
||||
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
|
||||
*/
|
||||
@@ -840,7 +938,10 @@ export const deletarMensagem = mutation({
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -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 ==========
|
||||
|
||||
/**
|
||||
* 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
|
||||
* SEGURANÇA: Usuário só vê conversas onde é participante
|
||||
@@ -947,11 +1410,36 @@ export const listarConversas = query({
|
||||
).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 {
|
||||
...conversa,
|
||||
outroUsuario,
|
||||
participantesInfo: participantes.filter((p) => p !== null),
|
||||
participantesInfo: participantesInfo.filter((p) => p !== null),
|
||||
naoLidas,
|
||||
isAdmin, // Adicionar flag de admin
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
@@ -619,10 +619,11 @@ export default defineSchema({
|
||||
|
||||
// Sistema de Chat
|
||||
conversas: defineTable({
|
||||
tipo: v.union(v.literal("individual"), v.literal("grupo")),
|
||||
nome: v.optional(v.string()), // nome do grupo
|
||||
avatar: v.optional(v.string()), // avatar do grupo
|
||||
tipo: v.union(v.literal("individual"), v.literal("grupo"), v.literal("sala_reuniao")),
|
||||
nome: v.optional(v.string()), // nome do grupo/sala
|
||||
avatar: v.optional(v.string()), // avatar do grupo/sala
|
||||
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()),
|
||||
ultimaMensagemTimestamp: v.optional(v.number()),
|
||||
criadoPor: v.id("usuarios"),
|
||||
|
||||
Reference in New Issue
Block a user