- Updated `lucide-svelte` dependency to version 0.552.0 across multiple files for consistency. - Refactored chat components to enhance structure and readability, including adjustments to the Sidebar, ChatList, and MessageInput components. - Improved notification handling in chat components to ensure better user experience and responsiveness. - Added type safety enhancements in various components to ensure better integration with backend data models.
413 lines
17 KiB
Svelte
413 lines
17 KiB
Svelte
<script lang="ts">
|
|
import { useQuery, useConvexClient } from "convex-svelte";
|
|
import { api } from "@sgse-app/backend/convex/_generated/api";
|
|
import { abrirConversa } from "$lib/stores/chatStore";
|
|
import { formatDistanceToNow } from "date-fns";
|
|
import { ptBR } from "date-fns/locale";
|
|
import UserStatusBadge from "./UserStatusBadge.svelte";
|
|
import UserAvatar from "./UserAvatar.svelte";
|
|
import NewConversationModal from "./NewConversationModal.svelte";
|
|
|
|
const client = useConvexClient();
|
|
|
|
// Buscar todos os usuários para o chat
|
|
const usuarios = useQuery(api.usuarios.listarParaChat, {});
|
|
|
|
// Buscar o perfil do usuário logado
|
|
const meuPerfil = useQuery(api.usuarios.obterPerfil, {});
|
|
|
|
// Buscar conversas (grupos e salas de reunião)
|
|
const conversas = useQuery(api.chat.listarConversas, {});
|
|
|
|
let searchQuery = $state("");
|
|
let activeTab = $state<"usuarios" | "conversas">("usuarios");
|
|
|
|
// Debug: monitorar carregamento de dados
|
|
$effect(() => {
|
|
console.log("📊 [ChatList] Usuários carregados:", usuarios?.data?.length || 0);
|
|
console.log("👤 [ChatList] Meu perfil:", meuPerfil?.data?.nome || "Carregando...");
|
|
console.log("🆔 [ChatList] Meu ID:", meuPerfil?.data?._id || "Não encontrado");
|
|
if (usuarios?.data) {
|
|
const meuId = meuPerfil?.data?._id;
|
|
const meusDadosNaLista = usuarios.data.find((u: any) => u._id === meuId);
|
|
if (meusDadosNaLista) {
|
|
console.warn("⚠️ [ChatList] ATENÇÃO: Meu usuário está na lista do backend!", meusDadosNaLista.nome);
|
|
}
|
|
}
|
|
});
|
|
|
|
const usuariosFiltrados = $derived.by(() => {
|
|
if (!usuarios?.data || !Array.isArray(usuarios.data)) return [];
|
|
|
|
// Se não temos o perfil ainda, retornar lista vazia para evitar mostrar usuários incorretos
|
|
if (!meuPerfil?.data) {
|
|
console.log("⏳ [ChatList] Aguardando perfil do usuário...");
|
|
return [];
|
|
}
|
|
|
|
const meuId = meuPerfil.data._id;
|
|
|
|
// Filtrar o próprio usuário da lista (filtro de segurança no frontend)
|
|
let listaFiltrada = usuarios.data.filter((u: any) => u._id !== meuId);
|
|
|
|
// Log se ainda estiver na lista após filtro (não deveria acontecer)
|
|
const aindaNaLista = listaFiltrada.find((u: any) => u._id === meuId);
|
|
if (aindaNaLista) {
|
|
console.error("❌ [ChatList] ERRO: Meu usuário ainda está na lista após filtro!");
|
|
}
|
|
|
|
// Aplicar busca por nome/email/matrícula
|
|
if (searchQuery.trim()) {
|
|
const query = searchQuery.toLowerCase();
|
|
listaFiltrada = listaFiltrada.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 listaFiltrada.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 formatarTempo(timestamp: number | undefined): string {
|
|
if (!timestamp) return "";
|
|
try {
|
|
return formatDistanceToNow(new Date(timestamp), {
|
|
addSuffix: true,
|
|
locale: ptBR,
|
|
});
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
let processando = $state(false);
|
|
let showNewConversationModal = $state(false);
|
|
|
|
async function handleClickUsuario(usuario: any) {
|
|
if (processando) {
|
|
console.log("⏳ Já está processando uma ação, aguarde...");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
processando = true;
|
|
console.log("🔄 Clicou no usuário:", usuario.nome, "ID:", usuario._id);
|
|
|
|
// Criar ou buscar conversa individual com este usuário
|
|
console.log("📞 Chamando mutation criarOuBuscarConversaIndividual...");
|
|
const conversaId = await client.mutation(api.chat.criarOuBuscarConversaIndividual, {
|
|
outroUsuarioId: usuario._id,
|
|
});
|
|
|
|
console.log("✅ Conversa criada/encontrada. ID:", conversaId);
|
|
|
|
// Abrir a conversa
|
|
console.log("📂 Abrindo conversa...");
|
|
abrirConversa(conversaId as any);
|
|
|
|
console.log("✅ Conversa aberta com sucesso!");
|
|
} catch (error) {
|
|
console.error("❌ Erro ao abrir conversa:", error);
|
|
console.error("Detalhes do erro:", {
|
|
message: error instanceof Error ? error.message : String(error),
|
|
stack: error instanceof Error ? error.stack : undefined,
|
|
usuario: usuario,
|
|
});
|
|
alert(`Erro ao abrir conversa: ${error instanceof Error ? error.message : String(error)}`);
|
|
} finally {
|
|
processando = false;
|
|
}
|
|
}
|
|
|
|
function getStatusLabel(status: string | undefined): string {
|
|
const labels: Record<string, string> = {
|
|
online: "Online",
|
|
offline: "Offline",
|
|
ausente: "Ausente",
|
|
externo: "Externo",
|
|
em_reuniao: "Em Reunião",
|
|
};
|
|
return labels[status || "offline"] || "Offline";
|
|
}
|
|
|
|
// Filtrar conversas por tipo e busca
|
|
const conversasFiltradas = $derived(() => {
|
|
if (!conversas?.data) return [];
|
|
|
|
let lista = conversas.data.filter((c: any) =>
|
|
c.tipo === "grupo" || c.tipo === "sala_reuniao"
|
|
);
|
|
|
|
// Aplicar busca
|
|
if (searchQuery.trim()) {
|
|
const query = searchQuery.toLowerCase();
|
|
lista = lista.filter((c: any) =>
|
|
c.nome?.toLowerCase().includes(query)
|
|
);
|
|
}
|
|
|
|
return lista;
|
|
});
|
|
|
|
function handleClickConversa(conversa: any) {
|
|
if (processando) return;
|
|
try {
|
|
processando = true;
|
|
abrirConversa(conversa._id);
|
|
} catch (error) {
|
|
console.error("Erro ao abrir conversa:", error);
|
|
alert(`Erro ao abrir conversa: ${error instanceof Error ? error.message : String(error)}`);
|
|
} finally {
|
|
processando = false;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div class="flex flex-col h-full">
|
|
<!-- Search bar -->
|
|
<div class="p-4 border-b border-base-300">
|
|
<div class="relative">
|
|
<input
|
|
type="text"
|
|
placeholder="Buscar usuários (nome, email, matrícula)..."
|
|
class="input input-bordered w-full pl-10"
|
|
bind:value={searchQuery}
|
|
/>
|
|
<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 absolute left-3 top-1/2 -translate-y-1/2 text-base-content/50"
|
|
>
|
|
<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>
|
|
|
|
<!-- Tabs e Título -->
|
|
<div class="border-b border-base-300 bg-base-200">
|
|
<!-- Tabs -->
|
|
<div class="tabs tabs-boxed p-2">
|
|
<button
|
|
type="button"
|
|
class={`tab flex-1 ${activeTab === "usuarios" ? "tab-active" : ""}`}
|
|
onclick={() => (activeTab = "usuarios")}
|
|
>
|
|
👥 Usuários ({usuariosFiltrados.length})
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class={`tab flex-1 ${activeTab === "conversas" ? "tab-active" : ""}`}
|
|
onclick={() => (activeTab = "conversas")}
|
|
>
|
|
💬 Conversas ({conversasFiltradas().length})
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Botão Nova Conversa -->
|
|
<div class="px-4 pb-2 flex justify-end">
|
|
<button
|
|
type="button"
|
|
class="btn btn-primary btn-sm"
|
|
onclick={() => (showNewConversationModal = true)}
|
|
title="Nova conversa (grupo ou sala de reunião)"
|
|
aria-label="Nova conversa"
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke-width="2"
|
|
stroke="currentColor"
|
|
class="w-4 h-4 mr-1"
|
|
>
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
|
</svg>
|
|
Nova Conversa
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Lista de conteúdo -->
|
|
<div class="flex-1 overflow-y-auto">
|
|
{#if activeTab === "usuarios"}
|
|
<!-- Lista de usuários -->
|
|
{#if usuarios?.data && usuariosFiltrados.length > 0}
|
|
{#each usuariosFiltrados as usuario (usuario._id)}
|
|
<button
|
|
type="button"
|
|
class="w-full text-left px-4 py-3 hover:bg-base-200 border-b border-base-300 transition-colors flex items-center gap-3 {processando ? 'opacity-50 cursor-wait' : 'cursor-pointer'}"
|
|
onclick={() => handleClickUsuario(usuario)}
|
|
disabled={processando}
|
|
>
|
|
<!-- Ícone de mensagem -->
|
|
<div class="flex-shrink-0 w-10 h-10 rounded-xl flex items-center justify-center transition-all duration-300 hover:scale-110"
|
|
style="background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%); border: 1px solid rgba(102, 126, 234, 0.2);">
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
class="w-5 h-5 text-primary"
|
|
>
|
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
|
<path d="M9 10h.01M15 10h.01"/>
|
|
</svg>
|
|
</div>
|
|
|
|
<!-- Avatar -->
|
|
<div class="relative flex-shrink-0">
|
|
<UserAvatar
|
|
avatar={usuario.avatar}
|
|
fotoPerfilUrl={usuario.fotoPerfilUrl}
|
|
nome={usuario.nome}
|
|
size="md"
|
|
/>
|
|
<!-- Status badge -->
|
|
<div class="absolute bottom-0 right-0">
|
|
<UserStatusBadge status={usuario.statusPresenca || "offline"} size="sm" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Conteúdo -->
|
|
<div class="flex-1 min-w-0">
|
|
<div class="flex items-center justify-between mb-1">
|
|
<p class="font-semibold text-base-content truncate">
|
|
{usuario.nome}
|
|
</p>
|
|
<span class="text-xs px-2 py-0.5 rounded-full {
|
|
usuario.statusPresenca === 'online' ? 'bg-success/20 text-success' :
|
|
usuario.statusPresenca === 'ausente' ? 'bg-warning/20 text-warning' :
|
|
usuario.statusPresenca === 'em_reuniao' ? 'bg-error/20 text-error' :
|
|
'bg-base-300 text-base-content/50'
|
|
}">
|
|
{getStatusLabel(usuario.statusPresenca)}
|
|
</span>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<p class="text-sm text-base-content/70 truncate">
|
|
{usuario.statusMensagem || usuario.email}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
{/each}
|
|
{:else if !usuarios?.data}
|
|
<!-- Loading -->
|
|
<div class="flex items-center justify-center h-full">
|
|
<span class="loading loading-spinner loading-lg"></span>
|
|
</div>
|
|
{:else}
|
|
<!-- Nenhum usuário encontrado -->
|
|
<div class="flex flex-col items-center justify-center h-full text-center px-4">
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke-width="1.5"
|
|
stroke="currentColor"
|
|
class="w-16 h-16 text-base-content/30 mb-4"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z"
|
|
/>
|
|
</svg>
|
|
<p class="text-base-content/70">Nenhum usuário encontrado</p>
|
|
</div>
|
|
{/if}
|
|
{:else}
|
|
<!-- Lista de conversas (grupos e salas) -->
|
|
{#if conversas?.data && conversasFiltradas().length > 0}
|
|
{#each conversasFiltradas() as conversa (conversa._id)}
|
|
<button
|
|
type="button"
|
|
class="w-full text-left px-4 py-3 hover:bg-base-200 border-b border-base-300 transition-colors flex items-center gap-3 {processando ? 'opacity-50 cursor-wait' : 'cursor-pointer'}"
|
|
onclick={() => handleClickConversa(conversa)}
|
|
disabled={processando}
|
|
>
|
|
<!-- Ícone de grupo/sala -->
|
|
<div class="flex-shrink-0 w-10 h-10 rounded-xl flex items-center justify-center transition-all duration-300 hover:scale-110 {
|
|
conversa.tipo === 'sala_reuniao'
|
|
? 'bg-gradient-to-br from-blue-500/20 to-purple-500/20 border border-blue-300/30'
|
|
: 'bg-gradient-to-br from-primary/20 to-secondary/20 border border-primary/30'
|
|
}">
|
|
{#if conversa.tipo === "sala_reuniao"}
|
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5 text-blue-500">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" />
|
|
</svg>
|
|
{:else}
|
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5 text-primary">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z" />
|
|
</svg>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Conteúdo -->
|
|
<div class="flex-1 min-w-0">
|
|
<div class="flex items-center justify-between mb-1">
|
|
<p class="font-semibold text-base-content truncate">
|
|
{conversa.nome || (conversa.tipo === "sala_reuniao" ? "Sala sem nome" : "Grupo sem nome")}
|
|
</p>
|
|
{#if conversa.naoLidas > 0}
|
|
<span class="badge badge-primary badge-sm">{conversa.naoLidas}</span>
|
|
{/if}
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-xs px-2 py-0.5 rounded-full {
|
|
conversa.tipo === 'sala_reuniao' ? 'bg-blue-500/20 text-blue-500' : 'bg-primary/20 text-primary'
|
|
}">
|
|
{conversa.tipo === "sala_reuniao" ? "👑 Sala de Reunião" : "👥 Grupo"}
|
|
</span>
|
|
{#if conversa.participantesInfo}
|
|
<span class="text-xs text-base-content/50">
|
|
{conversa.participantesInfo.length} participante{conversa.participantesInfo.length !== 1 ? 's' : ''}
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
{/each}
|
|
{:else if !conversas?.data}
|
|
<!-- Loading -->
|
|
<div class="flex items-center justify-center h-full">
|
|
<span class="loading loading-spinner loading-lg"></span>
|
|
</div>
|
|
{:else}
|
|
<!-- Nenhuma conversa encontrada -->
|
|
<div class="flex flex-col items-center justify-center h-full text-center px-4">
|
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-16 h-16 text-base-content/30 mb-4">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 0 1-.825-.242m9.345-8.334a2.126 2.126 0 0 0-.476-.095 48.64 48.64 0 0 0-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0 0 11.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155" />
|
|
</svg>
|
|
<p class="text-base-content/70 font-medium mb-2">Nenhuma conversa encontrada</p>
|
|
<p class="text-sm text-base-content/50">Crie um grupo ou sala de reunião para começar</p>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Modal de Nova Conversa -->
|
|
{#if showNewConversationModal}
|
|
<NewConversationModal onClose={() => (showNewConversationModal = false)} />
|
|
{/if}
|