Feat ausencia #7
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
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",
|
"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,
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
Reference in New Issue
Block a user