456 lines
15 KiB
Svelte
456 lines
15 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';
|
|
import { Search, Plus, MessageSquare, Users, UsersRound } from 'lucide-svelte';
|
|
import type { Id, Doc } from '@sgse-app/backend/convex/_generated/dataModel';
|
|
import { obterCoresDoTema } from '$lib/utils/temas';
|
|
|
|
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');
|
|
|
|
// Obter cores do tema atual (reativo)
|
|
let coresTema = $state(obterCoresDoTema());
|
|
|
|
// Atualizar cores quando o tema mudar
|
|
$effect(() => {
|
|
if (typeof window === 'undefined') return;
|
|
|
|
const atualizarCores = () => {
|
|
coresTema = obterCoresDoTema();
|
|
};
|
|
|
|
atualizarCores();
|
|
|
|
window.addEventListener('themechange', atualizarCores);
|
|
|
|
const observer = new MutationObserver(atualizarCores);
|
|
const htmlElement = document.documentElement;
|
|
observer.observe(htmlElement, {
|
|
attributes: true,
|
|
attributeFilter: ['data-theme']
|
|
});
|
|
|
|
return () => {
|
|
window.removeEventListener('themechange', atualizarCores);
|
|
observer.disconnect();
|
|
};
|
|
});
|
|
|
|
// Função para obter rgba da cor primária
|
|
function obterPrimariaRgba(alpha: number = 1) {
|
|
const primary = coresTema.primary;
|
|
if (primary.startsWith('rgba')) {
|
|
const match = primary.match(/rgba?\(([^)]+)\)/);
|
|
if (match) {
|
|
const values = match[1].split(',');
|
|
return `rgba(${values[0]}, ${values[1]}, ${values[2]}, ${alpha})`;
|
|
}
|
|
}
|
|
if (primary.startsWith('#')) {
|
|
const hex = primary.replace('#', '');
|
|
const r = parseInt(hex.substring(0, 2), 16);
|
|
const g = parseInt(hex.substring(2, 4), 16);
|
|
const b = parseInt(hex.substring(4, 6), 16);
|
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
|
}
|
|
if (primary.startsWith('hsl')) {
|
|
return primary.replace(/\)$/, `, ${alpha})`).replace('hsl', 'hsla');
|
|
}
|
|
return `rgba(102, 126, 234, ${alpha})`;
|
|
}
|
|
|
|
// 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) => u._id === meuId);
|
|
if (meusDadosNaLista) {
|
|
console.warn(
|
|
'⚠️ [ChatList] ATENÇÃO: Meu usuário está na lista do backend!',
|
|
meusDadosNaLista.nome
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
let 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) => u._id !== meuId);
|
|
|
|
// Log se ainda estiver na lista após filtro (não deveria acontecer)
|
|
const aindaNaLista = listaFiltrada.find((u) => 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) =>
|
|
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, b) => {
|
|
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 Id<'conversas'>);
|
|
|
|
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
|
|
let conversasFiltradas = $derived.by(() => {
|
|
if (!conversas?.data) return [];
|
|
|
|
let lista = conversas.data.filter(
|
|
(c: Doc<'conversas'>) => c.tipo === 'grupo' || c.tipo === 'sala_reuniao'
|
|
);
|
|
|
|
// Aplicar busca
|
|
if (searchQuery.trim()) {
|
|
const query = searchQuery.toLowerCase();
|
|
lista = lista.filter((c: Doc<'conversas'>) => c.nome?.toLowerCase().includes(query));
|
|
}
|
|
|
|
return lista;
|
|
});
|
|
|
|
interface Conversa {
|
|
_id: Id<'conversas'>;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
function handleClickConversa(conversa: Conversa) {
|
|
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 h-full flex-col">
|
|
<!-- Search bar -->
|
|
<div class="border-base-300 border-b p-4">
|
|
<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}
|
|
aria-label="Buscar usuários ou conversas"
|
|
aria-describedby="search-help"
|
|
/>
|
|
<span id="search-help" class="sr-only"
|
|
>Digite para buscar usuários por nome, email ou matrícula</span
|
|
>
|
|
<Search
|
|
class="text-base-content/50 absolute top-1/2 left-3 h-5 w-5 -translate-y-1/2"
|
|
strokeWidth={1.5}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tabs e Título -->
|
|
<div class="border-base-300 bg-base-200 border-b">
|
|
<!-- 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="flex justify-end px-4 pb-2">
|
|
<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"
|
|
>
|
|
<Plus class="mr-1 h-4 w-4" strokeWidth={2} />
|
|
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="hover:bg-base-200 border-base-300 flex w-full items-center gap-3 border-b px-4 py-3 text-left transition-colors {processando
|
|
? 'cursor-wait opacity-50'
|
|
: 'cursor-pointer'}"
|
|
onclick={() => handleClickUsuario(usuario)}
|
|
disabled={processando}
|
|
aria-label="Abrir conversa com {usuario.nome}"
|
|
aria-describedby="usuario-status-{usuario._id}"
|
|
>
|
|
<!-- Ícone de mensagem -->
|
|
<div
|
|
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl transition-all duration-300 hover:scale-110"
|
|
style="background: linear-gradient(135deg, {obterPrimariaRgba(0.1)} 0%, {obterPrimariaRgba(0.1)} 100%); border: 1px solid {obterPrimariaRgba(0.2)};"
|
|
>
|
|
<MessageSquare class="text-primary h-5 w-5" strokeWidth={2} />
|
|
</div>
|
|
|
|
<!-- Avatar -->
|
|
<div class="relative shrink-0">
|
|
<UserAvatar
|
|
fotoPerfilUrl={usuario.fotoPerfilUrl}
|
|
nome={usuario.nome}
|
|
size="md"
|
|
userId={usuario._id}
|
|
/>
|
|
<!-- Status badge -->
|
|
<div class="absolute right-0 bottom-0">
|
|
<UserStatusBadge status={usuario.statusPresenca || 'offline'} size="sm" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Conteúdo -->
|
|
<div class="min-w-0 flex-1">
|
|
<div class="mb-1 flex items-center justify-between">
|
|
<p class="text-base-content truncate font-semibold">
|
|
{usuario.nome}
|
|
</p>
|
|
<span
|
|
class="rounded-full px-2 py-0.5 text-xs {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-base-content/70 truncate text-sm">
|
|
{usuario.statusMensagem || usuario.email}
|
|
</p>
|
|
</div>
|
|
<span id="usuario-status-{usuario._id}" class="sr-only">
|
|
Status: {getStatusLabel(usuario.statusPresenca)}
|
|
</span>
|
|
</div>
|
|
</button>
|
|
{/each}
|
|
{:else if !usuarios?.data}
|
|
<!-- Loading -->
|
|
<div class="flex h-full items-center justify-center">
|
|
<span class="loading loading-spinner loading-lg"></span>
|
|
</div>
|
|
{:else}
|
|
<!-- Nenhum usuário encontrado -->
|
|
<div class="flex h-full flex-col items-center justify-center px-4 text-center">
|
|
<UsersRound class="text-base-content/30 mb-4 h-16 w-16" strokeWidth={1.5} />
|
|
<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="hover:bg-base-200 border-base-300 flex w-full items-center gap-3 border-b px-4 py-3 text-left transition-colors {processando
|
|
? 'cursor-wait opacity-50'
|
|
: 'cursor-pointer'}"
|
|
onclick={() => handleClickConversa(conversa)}
|
|
disabled={processando}
|
|
>
|
|
<!-- Ícone de grupo/sala -->
|
|
<div
|
|
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl transition-all duration-300 hover:scale-110 {conversa.tipo ===
|
|
'sala_reuniao'
|
|
? 'border border-blue-300/30 bg-linear-to-br from-blue-500/20 to-purple-500/20'
|
|
: 'from-primary/20 to-secondary/20 border-primary/30 border bg-linear-to-br'}"
|
|
>
|
|
{#if conversa.tipo === 'sala_reuniao'}
|
|
<UsersRound class="h-5 w-5 text-blue-500" strokeWidth={2} />
|
|
{:else}
|
|
<Users class="text-primary h-5 w-5" strokeWidth={2} />
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Conteúdo -->
|
|
<div class="min-w-0 flex-1">
|
|
<div class="mb-1 flex items-center justify-between">
|
|
<p class="text-base-content truncate font-semibold">
|
|
{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="rounded-full px-2 py-0.5 text-xs {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-base-content/50 text-xs">
|
|
{conversa.participantesInfo.length} participante{conversa.participantesInfo
|
|
.length !== 1
|
|
? 's'
|
|
: ''}
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
{/each}
|
|
{:else if !conversas?.data}
|
|
<!-- Loading -->
|
|
<div class="flex h-full items-center justify-center">
|
|
<span class="loading loading-spinner loading-lg"></span>
|
|
</div>
|
|
{:else}
|
|
<!-- Nenhuma conversa encontrada -->
|
|
<div class="flex h-full flex-col items-center justify-center px-4 text-center">
|
|
<MessageSquare class="text-base-content/30 mb-4 h-16 w-16" strokeWidth={1.5} />
|
|
<p class="text-base-content/70 mb-2 font-medium">Nenhuma conversa encontrada</p>
|
|
<p class="text-base-content/50 text-sm">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}
|