refactor: enhance chat components and improve user interaction

- Refactored Sidebar, ChatList, ChatWindow, and NewConversationModal components for better readability and maintainability.
- Updated user data handling to utilize the latest API responses, ensuring accurate display of user statuses and notifications.
- Improved modal interactions and user feedback mechanisms across chat components.
- Cleaned up unused code and optimized state management for a smoother user experience.
This commit is contained in:
2025-11-10 15:03:16 -03:00
parent 3cc774d7df
commit ed00739b30
6 changed files with 2249 additions and 2481 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,514 +1,448 @@
<script lang="ts"> <script lang="ts">
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 { formatDistanceToNow } from "date-fns"; import UserStatusBadge from './UserStatusBadge.svelte';
import { ptBR } from "date-fns/locale"; import UserAvatar from './UserAvatar.svelte';
import UserStatusBadge from "./UserStatusBadge.svelte"; import NewConversationModal from './NewConversationModal.svelte';
import UserAvatar from "./UserAvatar.svelte"; import type { Doc, Id } from '@sgse-app/backend/convex/_generated/dataModel';
import NewConversationModal from "./NewConversationModal.svelte";
const client = useConvexClient(); const client = useConvexClient();
// Buscar todos os usuários para o chat // Buscar todos os usuários para o chat
const usuarios = useQuery(api.usuarios.listarParaChat, {}); const usuarios = useQuery(api.usuarios.listarParaChat, {});
// 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) // Buscar conversas (grupos e salas de reunião)
const conversas = useQuery(api.chat.listarConversas, {}); const conversas = useQuery(api.chat.listarConversas, {});
let searchQuery = $state(""); let searchQuery = $state('');
let activeTab = $state<"usuarios" | "conversas">("usuarios"); let activeTab = $state<'usuarios' | 'conversas'>('usuarios');
// Debug: monitorar carregamento de dados const usuariosFiltrados = $derived.by(() => {
$effect(() => { if (!usuarios?.data || !Array.isArray(usuarios.data)) return [];
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(() => { // Se não temos o perfil ainda, retornar lista vazia para evitar mostrar usuários incorretos
if (!usuarios?.data || !Array.isArray(usuarios.data)) return []; if (!meuPerfil?.data) {
console.log('⏳ [ChatList] Aguardando perfil do usuário...');
return [];
}
// Se não temos o perfil ainda, retornar lista vazia para evitar mostrar usuários incorretos const meuId = meuPerfil.data._id;
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);
// Filtrar o próprio usuário da lista (filtro de segurança no frontend) // Log se ainda estiver na lista após filtro (não deveria acontecer)
let listaFiltrada = usuarios.data.filter((u: any) => u._id !== meuId); const aindaNaLista = listaFiltrada.find((u) => u._id === meuId);
if (aindaNaLista) {
console.error('❌ [ChatList] ERRO: Meu usuário ainda está na lista após filtro!');
}
// Log se ainda estiver na lista após filtro (não deveria acontecer) // Aplicar busca por nome/email/matrícula
const aindaNaLista = listaFiltrada.find((u: any) => u._id === meuId); if (searchQuery.trim()) {
if (aindaNaLista) { const query = searchQuery.toLowerCase();
console.error( listaFiltrada = listaFiltrada.filter(
"❌ [ChatList] ERRO: Meu usuário ainda está na lista após filtro!", (u) =>
); u.nome?.toLowerCase().includes(query) ||
} u.email?.toLowerCase().includes(query) ||
u.matricula?.toLowerCase().includes(query)
);
}
// Aplicar busca por nome/email/matrícula // Ordenar: Online primeiro, depois por nome
if (searchQuery.trim()) { return listaFiltrada.sort((a, b) => {
const query = searchQuery.toLowerCase(); const statusOrder = {
listaFiltrada = listaFiltrada.filter( online: 0,
(u: any) => ausente: 1,
u.nome?.toLowerCase().includes(query) || externo: 2,
u.email?.toLowerCase().includes(query) || em_reuniao: 3,
u.matricula?.toLowerCase().includes(query), offline: 4
); };
} const statusA = statusOrder[a.statusPresenca as keyof typeof statusOrder] ?? 4;
const statusB = statusOrder[b.statusPresenca as keyof typeof statusOrder] ?? 4;
// Ordenar: Online primeiro, depois por nome if (statusA !== statusB) return statusA - statusB;
return listaFiltrada.sort((a: any, b: any) => { return a.nome.localeCompare(b.nome);
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; let processando = $state(false);
return a.nome.localeCompare(b.nome); let showNewConversationModal = $state(false);
});
});
function formatarTempo(timestamp: number | undefined): string { async function handleClickUsuario(usuario: {
if (!timestamp) return ""; _id: Id<'usuarios'>;
try { nome: string;
return formatDistanceToNow(new Date(timestamp), { email: string;
addSuffix: true, matricula: string | undefined;
locale: ptBR, avatar: string | undefined;
}); fotoPerfil: Id<'_storage'> | undefined;
} catch { fotoPerfilUrl: string | null;
return ""; statusPresenca: 'online' | 'offline' | 'ausente' | 'externo' | 'em_reuniao';
} statusMensagem: string | undefined;
} ultimaAtividade: number | undefined;
}) {
if (processando) {
console.log('⏳ Já está processando uma ação, aguarde...');
return;
}
let processando = $state(false); try {
let showNewConversationModal = $state(false); processando = true;
console.log('🔄 Clicou no usuário:', usuario.nome, 'ID:', usuario._id);
async function handleClickUsuario(usuario: any) { // Criar ou buscar conversa individual com este usuário
if (processando) { console.log('📞 Chamando mutation criarOuBuscarConversaIndividual...');
console.log("⏳ Já está processando uma ação, aguarde..."); const conversaId = await client.mutation(api.chat.criarOuBuscarConversaIndividual, {
return; outroUsuarioId: usuario._id
} });
try { console.log('✅ Conversa criada/encontrada. ID:', conversaId);
processando = true;
console.log("🔄 Clicou no usuário:", usuario.nome, "ID:", usuario._id);
// Criar ou buscar conversa individual com este usuário // Abrir a conversa
console.log("📞 Chamando mutation criarOuBuscarConversaIndividual..."); console.log('📂 Abrindo conversa...');
const conversaId = await client.mutation( abrirConversa(conversaId);
api.chat.criarOuBuscarConversaIndividual,
{
outroUsuarioId: usuario._id,
},
);
console.log("✅ Conversa criada/encontrada. ID:", conversaId); 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;
}
}
// Abrir a conversa function getStatusLabel(status: string | undefined): string {
console.log("📂 Abrindo conversa..."); const labels: Record<string, string> = {
abrirConversa(conversaId as any); online: 'Online',
offline: 'Offline',
ausente: 'Ausente',
externo: 'Externo',
em_reuniao: 'Em Reunião'
};
return labels[status || 'offline'] || 'Offline';
}
console.log("✅ Conversa aberta com sucesso!"); // Filtrar conversas por tipo e busca
} catch (error) { const conversasFiltradas = $derived(() => {
console.error("❌ Erro ao abrir conversa:", error); if (!conversas?.data) return [];
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 { let lista = conversas.data.filter((c) => c.tipo === 'grupo' || c.tipo === 'sala_reuniao');
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 // Aplicar busca
const conversasFiltradas = $derived(() => { if (searchQuery.trim()) {
if (!conversas?.data) return []; const query = searchQuery.toLowerCase();
lista = lista.filter((c) => c.nome?.toLowerCase().includes(query));
}
let lista = conversas.data.filter( return lista;
(c: any) => c.tipo === "grupo" || c.tipo === "sala_reuniao", });
);
// Aplicar busca function handleClickConversa(conversa: Doc<'conversas'>) {
if (searchQuery.trim()) { if (processando) return;
const query = searchQuery.toLowerCase(); try {
lista = lista.filter((c: any) => c.nome?.toLowerCase().includes(query)); processando = true;
} abrirConversa(conversa._id);
} catch (error) {
return lista; console.error('Erro ao abrir conversa:', error);
}); alert(`Erro ao abrir conversa: ${error instanceof Error ? error.message : String(error)}`);
} finally {
function handleClickConversa(conversa: any) { processando = false;
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 h-full flex-col">
<!-- Search bar --> <!-- Search bar -->
<div class="p-4 border-b border-base-300"> <div class="border-base-300 border-b p-4">
<div class="relative"> <div class="relative">
<input <input
type="text" type="text"
placeholder="Buscar usuários (nome, email, matrícula)..." placeholder="Buscar usuários (nome, email, matrícula)..."
class="input input-bordered w-full pl-10" class="input input-bordered w-full pl-10"
bind:value={searchQuery} bind:value={searchQuery}
/> />
<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="1.5"
stroke="currentColor" stroke="currentColor"
class="w-5 h-5 absolute left-3 top-1/2 -translate-y-1/2 text-base-content/50" class="text-base-content/50 absolute top-1/2 left-3 h-5 w-5 -translate-y-1/2"
> >
<path <path
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="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" 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> </svg>
</div> </div>
</div> </div>
<!-- Tabs e Título --> <!-- Tabs e Título -->
<div class="border-b border-base-300 bg-base-200"> <div class="border-base-300 bg-base-200 border-b">
<!-- Tabs --> <!-- Tabs -->
<div class="tabs tabs-boxed p-2"> <div class="tabs tabs-boxed p-2">
<button <button
type="button" type="button"
class={`tab flex-1 ${activeTab === "usuarios" ? "tab-active" : ""}`} class={`tab flex-1 ${activeTab === 'usuarios' ? 'tab-active' : ''}`}
onclick={() => (activeTab = "usuarios")} onclick={() => (activeTab = 'usuarios')}
> >
👥 Usuários ({usuariosFiltrados.length}) 👥 Usuários ({usuariosFiltrados.length})
</button> </button>
<button <button
type="button" type="button"
class={`tab flex-1 ${activeTab === "conversas" ? "tab-active" : ""}`} class={`tab flex-1 ${activeTab === 'conversas' ? 'tab-active' : ''}`}
onclick={() => (activeTab = "conversas")} onclick={() => (activeTab = 'conversas')}
> >
💬 Conversas ({conversasFiltradas().length}) 💬 Conversas ({conversasFiltradas().length})
</button> </button>
</div> </div>
<!-- Botão Nova Conversa --> <!-- Botão Nova Conversa -->
<div class="px-4 pb-2 flex justify-end"> <div class="flex justify-end px-4 pb-2">
<button <button
type="button" type="button"
class="btn btn-primary btn-sm" class="btn btn-primary btn-sm"
onclick={() => (showNewConversationModal = true)} onclick={() => (showNewConversationModal = true)}
title="Nova conversa (grupo ou sala de reunião)" title="Nova conversa (grupo ou sala de reunião)"
aria-label="Nova conversa" aria-label="Nova conversa"
> >
<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="2" stroke-width="2"
stroke="currentColor" stroke="currentColor"
class="w-4 h-4 mr-1" class="mr-1 h-4 w-4"
> >
<path <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
stroke-linecap="round" </svg>
stroke-linejoin="round" Nova Conversa
d="M12 4.5v15m7.5-7.5h-15" </button>
/> </div>
</svg> </div>
Nova Conversa
</button>
</div>
</div>
<!-- Lista de conteúdo --> <!-- Lista de conteúdo -->
<div class="flex-1 overflow-y-auto"> <div class="flex-1 overflow-y-auto">
{#if activeTab === "usuarios"} {#if activeTab === 'usuarios'}
<!-- Lista de usuários --> <!-- Lista de usuários -->
{#if usuarios?.data && usuariosFiltrados.length > 0} {#if usuarios?.data && usuariosFiltrados.length > 0}
{#each usuariosFiltrados as usuario (usuario._id)} {#each usuariosFiltrados as usuario (usuario._id)}
<button <button
type="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 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
? 'opacity-50 cursor-wait' ? 'cursor-wait opacity-50'
: 'cursor-pointer'}" : 'cursor-pointer'}"
onclick={() => handleClickUsuario(usuario)} onclick={() => handleClickUsuario(usuario)}
disabled={processando} disabled={processando}
> >
<!-- Ícone de mensagem --> <!-- Ícone de mensagem -->
<div <div
class="shrink-0 w-10 h-10 rounded-xl flex items-center justify-center transition-all duration-300 hover:scale-110" 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, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%); border: 1px solid rgba(102, 126, 234, 0.2);" 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 <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
stroke-width="2" stroke-width="2"
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
class="w-5 h-5 text-primary" class="text-primary h-5 w-5"
> >
<path <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
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>
<path d="M9 10h.01M15 10h.01" /> </div>
</svg>
</div>
<!-- Avatar --> <!-- Avatar -->
<div class="relative shrink-0"> <div class="relative shrink-0">
<UserAvatar <UserAvatar
avatar={usuario.avatar} avatar={usuario.avatar}
fotoPerfilUrl={usuario.fotoPerfilUrl} fotoPerfilUrl={usuario.fotoPerfilUrl}
nome={usuario.nome} nome={usuario.nome}
size="md" size="md"
/> />
<!-- Status badge --> <!-- Status badge -->
<div class="absolute bottom-0 right-0"> <div class="absolute right-0 bottom-0">
<UserStatusBadge <UserStatusBadge status={usuario.statusPresenca || 'offline'} size="sm" />
status={usuario.statusPresenca || "offline"} </div>
size="sm" </div>
/>
</div>
</div>
<!-- Conteúdo --> <!-- Conteúdo -->
<div class="flex-1 min-w-0"> <div class="min-w-0 flex-1">
<div class="flex items-center justify-between mb-1"> <div class="mb-1 flex items-center justify-between">
<p class="font-semibold text-base-content truncate"> <p class="text-base-content truncate font-semibold">
{usuario.nome} {usuario.nome}
</p> </p>
<span <span
class="text-xs px-2 py-0.5 rounded-full {usuario.statusPresenca === class="rounded-full px-2 py-0.5 text-xs {usuario.statusPresenca === 'online'
'online' ? 'bg-success/20 text-success'
? 'bg-success/20 text-success' : usuario.statusPresenca === 'ausente'
: usuario.statusPresenca === 'ausente' ? 'bg-warning/20 text-warning'
? 'bg-warning/20 text-warning' : usuario.statusPresenca === 'em_reuniao'
: usuario.statusPresenca === 'em_reuniao' ? 'bg-error/20 text-error'
? 'bg-error/20 text-error' : 'bg-base-300 text-base-content/50'}"
: 'bg-base-300 text-base-content/50'}" >
> {getStatusLabel(usuario.statusPresenca)}
{getStatusLabel(usuario.statusPresenca)} </span>
</span> </div>
</div> <div class="flex items-center gap-2">
<div class="flex items-center gap-2"> <p class="text-base-content/70 truncate text-sm">
<p class="text-sm text-base-content/70 truncate"> {usuario.statusMensagem || usuario.email}
{usuario.statusMensagem || usuario.email} </p>
</p> </div>
</div> </div>
</div> </button>
</button> {/each}
{/each} {:else if !usuarios?.data}
{:else if !usuarios?.data} <!-- Loading -->
<!-- Loading --> <div class="flex h-full items-center justify-center">
<div class="flex items-center justify-center h-full"> <span class="loading loading-spinner loading-lg"></span>
<span class="loading loading-spinner loading-lg"></span> </div>
</div> {:else}
{:else} <!-- Nenhum usuário encontrado -->
<!-- Nenhum usuário encontrado --> <div class="flex h-full flex-col items-center justify-center px-4 text-center">
<div <svg
class="flex flex-col items-center justify-center h-full text-center px-4" xmlns="http://www.w3.org/2000/svg"
> fill="none"
<svg viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg" stroke-width="1.5"
fill="none" stroke="currentColor"
viewBox="0 0 24 24" class="text-base-content/30 mb-4 h-16 w-16"
stroke-width="1.5" >
stroke="currentColor" <path
class="w-16 h-16 text-base-content/30 mb-4" stroke-linecap="round"
> stroke-linejoin="round"
<path 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"
stroke-linecap="round" />
stroke-linejoin="round" </svg>
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" <p class="text-base-content/70">Nenhum usuário encontrado</p>
/> </div>
</svg> {/if}
<p class="text-base-content/70">Nenhum usuário encontrado</p> {:else}
</div> <!-- Lista de conversas (grupos e salas) -->
{/if} {#if conversas?.data && conversasFiltradas().length > 0}
{:else} {#each conversasFiltradas() as conversa (conversa._id)}
<!-- Lista de conversas (grupos e salas) --> <button
{#if conversas?.data && conversasFiltradas().length > 0} type="button"
{#each conversasFiltradas() as conversa (conversa._id)} 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
<button ? 'cursor-wait opacity-50'
type="button" : 'cursor-pointer'}"
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 onclick={() => handleClickConversa(conversa)}
? 'opacity-50 cursor-wait' disabled={processando}
: 'cursor-pointer'}" >
onclick={() => handleClickConversa(conversa)} <!-- Ícone de grupo/sala -->
disabled={processando} <div
> class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl transition-all duration-300 hover:scale-110 {conversa.tipo ===
<!-- Ícone de grupo/sala --> 'sala_reuniao'
<div ? 'border border-blue-300/30 bg-linear-to-br from-blue-500/20 to-purple-500/20'
class="shrink-0 w-10 h-10 rounded-xl flex items-center justify-center transition-all duration-300 hover:scale-110 {conversa.tipo === : 'from-primary/20 to-secondary/20 border-primary/30 border bg-linear-to-br'}"
'sala_reuniao' >
? 'bg-linear-to-br from-blue-500/20 to-purple-500/20 border border-blue-300/30' {#if conversa.tipo === 'sala_reuniao'}
: 'bg-linear-to-br from-primary/20 to-secondary/20 border border-primary/30'}" <svg
> xmlns="http://www.w3.org/2000/svg"
{#if conversa.tipo === "sala_reuniao"} fill="none"
<svg viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg" stroke-width="2"
fill="none" stroke="currentColor"
viewBox="0 0 24 24" class="h-5 w-5 text-blue-500"
stroke-width="2" >
stroke="currentColor" <path
class="w-5 h-5 text-blue-500" stroke-linecap="round"
> stroke-linejoin="round"
<path 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"
stroke-linecap="round" />
stroke-linejoin="round" </svg>
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" {:else}
/> <svg
</svg> xmlns="http://www.w3.org/2000/svg"
{:else} fill="none"
<svg viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg" stroke-width="2"
fill="none" stroke="currentColor"
viewBox="0 0 24 24" class="text-primary h-5 w-5"
stroke-width="2" >
stroke="currentColor" <path
class="w-5 h-5 text-primary" stroke-linecap="round"
> stroke-linejoin="round"
<path 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"
stroke-linecap="round" />
stroke-linejoin="round" </svg>
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" {/if}
/> </div>
</svg>
{/if}
</div>
<!-- Conteúdo --> <!-- Conteúdo -->
<div class="flex-1 min-w-0"> <div class="min-w-0 flex-1">
<div class="flex items-center justify-between mb-1"> <div class="mb-1 flex items-center justify-between">
<p class="font-semibold text-base-content truncate"> <p class="text-base-content truncate font-semibold">
{conversa.nome || {conversa.nome ||
(conversa.tipo === "sala_reuniao" (conversa.tipo === 'sala_reuniao' ? 'Sala sem nome' : 'Grupo sem nome')}
? "Sala sem nome" </p>
: "Grupo sem nome")} {#if conversa.naoLidas > 0}
</p> <span class="badge badge-primary badge-sm">{conversa.naoLidas}</span>
{#if conversa.naoLidas > 0} {/if}
<span class="badge badge-primary badge-sm" </div>
>{conversa.naoLidas}</span <div class="flex items-center gap-2">
> <span
{/if} class="rounded-full px-2 py-0.5 text-xs {conversa.tipo === 'sala_reuniao'
</div> ? 'bg-blue-500/20 text-blue-500'
<div class="flex items-center gap-2"> : 'bg-primary/20 text-primary'}"
<span >
class="text-xs px-2 py-0.5 rounded-full {conversa.tipo === {conversa.tipo === 'sala_reuniao' ? '👑 Sala de Reunião' : '👥 Grupo'}
'sala_reuniao' </span>
? 'bg-blue-500/20 text-blue-500' {#if conversa.participantesInfo}
: 'bg-primary/20 text-primary'}" <span class="text-base-content/50 text-xs">
> {conversa.participantesInfo.length} participante{conversa.participantesInfo
{conversa.tipo === "sala_reuniao" .length !== 1
? "👑 Sala de Reunião" ? 's'
: "👥 Grupo"} : ''}
</span> </span>
{#if conversa.participantesInfo} {/if}
<span class="text-xs text-base-content/50"> </div>
{conversa.participantesInfo.length} participante{conversa </div>
.participantesInfo.length !== 1 </button>
? "s" {/each}
: ""} {:else if !conversas?.data}
</span> <!-- Loading -->
{/if} <div class="flex h-full items-center justify-center">
</div> <span class="loading loading-spinner loading-lg"></span>
</div> </div>
</button> {:else}
{/each} <!-- Nenhuma conversa encontrada -->
{:else if !conversas?.data} <div class="flex h-full flex-col items-center justify-center px-4 text-center">
<!-- Loading --> <svg
<div class="flex items-center justify-center h-full"> xmlns="http://www.w3.org/2000/svg"
<span class="loading loading-spinner loading-lg"></span> fill="none"
</div> viewBox="0 0 24 24"
{:else} stroke-width="1.5"
<!-- Nenhuma conversa encontrada --> stroke="currentColor"
<div class="text-base-content/30 mb-4 h-16 w-16"
class="flex flex-col items-center justify-center h-full text-center px-4" >
> <path
<svg stroke-linecap="round"
xmlns="http://www.w3.org/2000/svg" stroke-linejoin="round"
fill="none" 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"
viewBox="0 0 24 24" />
stroke-width="1.5" </svg>
stroke="currentColor" <p class="text-base-content/70 mb-2 font-medium">Nenhuma conversa encontrada</p>
class="w-16 h-16 text-base-content/30 mb-4" <p class="text-base-content/50 text-sm">Crie um grupo ou sala de reunião para começar</p>
> </div>
<path {/if}
stroke-linecap="round" {/if}
stroke-linejoin="round" </div>
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> </div>
<!-- Modal de Nova Conversa --> <!-- Modal de Nova Conversa -->
{#if showNewConversationModal} {#if showNewConversationModal}
<NewConversationModal onClose={() => (showNewConversationModal = false)} /> <NewConversationModal onClose={() => (showNewConversationModal = false)} />
{/if} {/if}

File diff suppressed because it is too large Load Diff

View File

@@ -1,454 +1,417 @@
<script lang="ts"> <script lang="ts">
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 UserStatusBadge from "./UserStatusBadge.svelte"; import UserStatusBadge from './UserStatusBadge.svelte';
import UserAvatar from "./UserAvatar.svelte"; import UserAvatar from './UserAvatar.svelte';
import { import {
MessageSquare, MessageSquare,
User, User,
Users, Users,
Video, Video,
X, X,
Search, Search,
ChevronRight, ChevronRight,
Plus, Plus,
UserX, UserX
} from "lucide-svelte"; } from 'lucide-svelte';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
interface Props { interface Props {
onClose: () => void; onClose: () => void;
} }
let { onClose }: Props = $props(); let { onClose }: Props = $props();
const client = useConvexClient(); const client = useConvexClient();
const usuarios = useQuery(api.usuarios.listarParaChat, {}); const usuarios = useQuery(api.usuarios.listarParaChat, {});
const meuPerfil = useQuery(api.usuarios.obterPerfil, {}); const meuPerfil = useQuery(api.usuarios.obterPerfil, {});
// Usuário atual // Usuário atual
const currentUser = useQuery(api.auth.getCurrentUser, {}); const currentUser = useQuery(api.auth.getCurrentUser, {});
let activeTab = $state<"individual" | "grupo" | "sala_reuniao">("individual"); let activeTab = $state<'individual' | 'grupo' | 'sala_reuniao'>('individual');
let searchQuery = $state(""); let searchQuery = $state('');
let selectedUsers = $state<string[]>([]); let selectedUsers = $state<Id<'usuarios'>[]>([]);
let groupName = $state(""); let groupName = $state('');
let salaReuniaoName = $state(""); let salaReuniaoName = $state('');
let loading = $state(false); let loading = $state(false);
const usuariosFiltrados = $derived(() => { const usuariosFiltrados = $derived(() => {
if (!usuarios?.data) return []; if (!usuarios?.data) return [];
// Filtrar o próprio usuário // Filtrar o próprio usuário
const meuId = currentUser?.data?._id || meuPerfil?.data?._id; const meuId = currentUser?.data?._id || meuPerfil?.data?._id;
let lista = usuarios.data.filter((u: any) => u._id !== meuId); let lista = usuarios.data.filter((u) => u._id !== meuId);
// Aplicar busca // Aplicar busca
if (searchQuery.trim()) { if (searchQuery.trim()) {
const query = searchQuery.toLowerCase(); const query = searchQuery.toLowerCase();
lista = lista.filter( lista = lista.filter(
(u: any) => (u) =>
u.nome?.toLowerCase().includes(query) || u.nome?.toLowerCase().includes(query) ||
u.email?.toLowerCase().includes(query) || u.email?.toLowerCase().includes(query) ||
u.matricula?.toLowerCase().includes(query), u.matricula?.toLowerCase().includes(query)
); );
} }
// Ordenar: online primeiro, depois por nome // Ordenar: online primeiro, depois por nome
return lista.sort((a: any, b: any) => { return lista.sort((a, b) => {
const statusOrder = { const statusOrder = {
online: 0, online: 0,
ausente: 1, ausente: 1,
externo: 2, externo: 2,
em_reuniao: 3, em_reuniao: 3,
offline: 4, offline: 4
}; };
const statusA = const statusA = statusOrder[a.statusPresenca as keyof typeof statusOrder] ?? 4;
statusOrder[a.statusPresenca as keyof typeof statusOrder] ?? 4; const statusB = statusOrder[b.statusPresenca as keyof typeof statusOrder] ?? 4;
const statusB =
statusOrder[b.statusPresenca as keyof typeof statusOrder] ?? 4;
if (statusA !== statusB) return statusA - statusB; if (statusA !== statusB) return statusA - statusB;
return (a.nome || "").localeCompare(b.nome || ""); return (a.nome || '').localeCompare(b.nome || '');
}); });
}); });
function toggleUserSelection(userId: string) { function toggleUserSelection(userId: Id<'usuarios'>) {
if (selectedUsers.includes(userId)) { if (selectedUsers.includes(userId)) {
selectedUsers = selectedUsers.filter((id) => id !== userId); selectedUsers = selectedUsers.filter((id) => id !== userId);
} else { } else {
selectedUsers = [...selectedUsers, userId]; selectedUsers = [...selectedUsers, userId];
} }
} }
async function handleCriarIndividual(userId: string) { async function handleCriarIndividual(userId: Id<'usuarios'>) {
try { try {
loading = true; loading = true;
const conversaId = await client.mutation(api.chat.criarConversa, { const conversaId = await client.mutation(api.chat.criarConversa, {
tipo: "individual", tipo: 'individual',
participantes: [userId as any], participantes: [userId]
}); });
abrirConversa(conversaId); abrirConversa(conversaId);
onClose(); onClose();
} catch (error) { } catch (error) {
console.error("Erro ao criar conversa:", error); console.error('Erro ao criar conversa:', error);
alert("Erro ao criar conversa"); alert('Erro ao criar conversa');
} finally { } finally {
loading = false; loading = false;
} }
} }
async function handleCriarGrupo() { async function handleCriarGrupo() {
if (selectedUsers.length < 2) { if (selectedUsers.length < 2) {
alert("Selecione pelo menos 2 participantes"); alert('Selecione pelo menos 2 participantes');
return; return;
} }
if (!groupName.trim()) { if (!groupName.trim()) {
alert("Digite um nome para o grupo"); alert('Digite um nome para o grupo');
return; return;
} }
try { try {
loading = true; loading = true;
const conversaId = await client.mutation(api.chat.criarConversa, { const conversaId = await client.mutation(api.chat.criarConversa, {
tipo: "grupo", tipo: 'grupo',
participantes: selectedUsers as any, participantes: selectedUsers,
nome: groupName.trim(), nome: groupName.trim()
}); });
abrirConversa(conversaId); abrirConversa(conversaId);
onClose(); onClose();
} catch (error: any) { } catch (error) {
console.error("Erro ao criar grupo:", error); console.error('Erro ao criar grupo:', error);
const mensagem = alert('Erro ao criar grupo');
error?.message || error?.data || "Erro desconhecido ao criar grupo"; } finally {
alert(`Erro ao criar grupo: ${mensagem}`); loading = false;
} finally { }
loading = false; }
}
}
async function handleCriarSalaReuniao() { async function handleCriarSalaReuniao() {
if (selectedUsers.length < 1) { if (selectedUsers.length < 1) {
alert("Selecione pelo menos 1 participante"); alert('Selecione pelo menos 1 participante');
return; return;
} }
if (!salaReuniaoName.trim()) { if (!salaReuniaoName.trim()) {
alert("Digite um nome para a sala de reunião"); alert('Digite um nome para a sala de reunião');
return; return;
} }
try { try {
loading = true; loading = true;
const conversaId = await client.mutation(api.chat.criarSalaReuniao, { const conversaId = await client.mutation(api.chat.criarSalaReuniao, {
nome: salaReuniaoName.trim(), nome: salaReuniaoName.trim(),
participantes: selectedUsers as any, participantes: selectedUsers
}); });
abrirConversa(conversaId); abrirConversa(conversaId);
onClose(); onClose();
} catch (error: any) { } catch (error) {
console.error("Erro ao criar sala de reunião:", error); console.error('Erro ao criar sala de reunião:', error);
const mensagem = alert('Erro ao criar sala de reunião');
error?.message || } finally {
error?.data || loading = false;
"Erro desconhecido ao criar sala de reunião"; }
alert(`Erro ao criar sala de reunião: ${mensagem}`); }
} finally {
loading = false;
}
}
</script> </script>
<dialog <dialog class="modal modal-open">
class="modal modal-open" <div class="modal-box flex max-h-[85vh] max-w-2xl flex-col p-0">
onclick={(e) => e.target === e.currentTarget && onClose()} <!-- Header -->
> <div class="border-base-300 flex items-center justify-between border-b px-6 py-4">
<div <h2 class="flex items-center gap-2 text-2xl font-bold">
class="modal-box max-w-2xl max-h-[85vh] flex flex-col p-0" <MessageSquare class="text-primary h-6 w-6" />
onclick={(e) => e.stopPropagation()} Nova Conversa
> </h2>
<!-- Header --> <button type="button" class="btn btn-sm btn-circle" onclick={onClose} aria-label="Fechar">
<div <X class="h-5 w-5" />
class="flex items-center justify-between px-6 py-4 border-b border-base-300" </button>
> </div>
<h2 class="text-2xl font-bold flex items-center gap-2">
<MessageSquare class="w-6 h-6 text-primary" />
Nova Conversa
</h2>
<button
type="button"
class="btn btn-ghost btn-sm btn-circle"
onclick={onClose}
aria-label="Fechar"
>
<X class="w-5 h-5" />
</button>
</div>
<!-- Tabs melhoradas --> <!-- Tabs melhoradas -->
<div class="tabs tabs-boxed p-4 bg-base-200/50"> <div class="tabs tabs-boxed bg-base-200/50 p-4">
<button <button
type="button" type="button"
class={`tab flex items-center gap-2 transition-all duration-200 ${ class={`tab flex items-center gap-2 transition-all duration-200 ${
activeTab === "individual" activeTab === 'individual'
? "tab-active bg-primary text-primary-content font-semibold" ? 'tab-active bg-primary text-primary-content font-semibold'
: "hover:bg-base-300" : 'hover:bg-base-300'
}`} }`}
onclick={() => { onclick={() => {
activeTab = "individual"; activeTab = 'individual';
selectedUsers = []; selectedUsers = [];
searchQuery = ""; searchQuery = '';
}} }}
> >
<User class="w-4 h-4" /> <User class="h-4 w-4" />
Individual Individual
</button> </button>
<button <button
type="button" type="button"
class={`tab flex items-center gap-2 transition-all duration-200 ${ class={`tab flex items-center gap-2 transition-all duration-200 ${
activeTab === "grupo" activeTab === 'grupo'
? "tab-active bg-primary text-primary-content font-semibold" ? 'tab-active bg-primary text-primary-content font-semibold'
: "hover:bg-base-300" : 'hover:bg-base-300'
}`} }`}
onclick={() => { onclick={() => {
activeTab = "grupo"; activeTab = 'grupo';
selectedUsers = []; selectedUsers = [];
searchQuery = ""; searchQuery = '';
}} }}
> >
<Users class="w-4 h-4" /> <Users class="h-4 w-4" />
Grupo Grupo
</button> </button>
<button <button
type="button" type="button"
class={`tab flex items-center gap-2 transition-all duration-200 ${ class={`tab flex items-center gap-2 transition-all duration-200 ${
activeTab === "sala_reuniao" activeTab === 'sala_reuniao'
? "tab-active bg-primary text-primary-content font-semibold" ? 'tab-active bg-primary text-primary-content font-semibold'
: "hover:bg-base-300" : 'hover:bg-base-300'
}`} }`}
onclick={() => { onclick={() => {
activeTab = "sala_reuniao"; activeTab = 'sala_reuniao';
selectedUsers = []; selectedUsers = [];
searchQuery = ""; searchQuery = '';
}} }}
> >
<Video class="w-4 h-4" /> <Video class="h-4 w-4" />
Sala de Reunião Sala de Reunião
</button> </button>
</div> </div>
<!-- Content --> <!-- Content -->
<div class="flex-1 overflow-y-auto px-6 py-4"> <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 pb-2"> <div class="label pb-2">
<span class="label-text font-semibold">Nome do Grupo</span> <span class="label-text font-semibold">Nome do Grupo</span>
</label> </div>
<input <input
type="text" type="text"
placeholder="Digite o nome do grupo..." placeholder="Digite o nome do grupo..."
class="input input-bordered w-full focus:input-primary transition-colors" class="input input-bordered focus:input-primary w-full transition-colors"
bind:value={groupName} bind:value={groupName}
maxlength="50" maxlength="50"
/> />
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="label pb-2"> <div class="label pb-2">
<span class="label-text font-semibold"> <span class="label-text font-semibold">
Participantes {selectedUsers.length > 0 Participantes {selectedUsers.length > 0
? `(${selectedUsers.length} selecionado${selectedUsers.length > 1 ? "s" : ""})` ? `(${selectedUsers.length} selecionado${selectedUsers.length > 1 ? 's' : ''})`
: ""} : ''}
</span> </span>
</label> </div>
</div> </div>
{:else if activeTab === "sala_reuniao"} {:else if activeTab === 'sala_reuniao'}
<!-- Criar Sala de Reunião --> <!-- Criar Sala de Reunião -->
<div class="mb-4"> <div class="mb-4">
<label class="label pb-2"> <div class="label pb-2">
<span class="label-text font-semibold">Nome da Sala de Reunião</span <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
<span class="label-text-alt text-primary font-medium" >
>👑 Você será o administrador</span </div>
> <input
</label> type="text"
<input placeholder="Digite o nome da sala de reunião..."
type="text" class="input input-bordered focus:input-primary w-full transition-colors"
placeholder="Digite o nome da sala de reunião..." bind:value={salaReuniaoName}
class="input input-bordered w-full focus:input-primary transition-colors" maxlength="50"
bind:value={salaReuniaoName} />
maxlength="50" </div>
/>
</div>
<div class="mb-3"> <div class="mb-3">
<label class="label pb-2"> <div class="label pb-2">
<span class="label-text font-semibold"> <span class="label-text font-semibold">
Participantes {selectedUsers.length > 0 Participantes {selectedUsers.length > 0
? `(${selectedUsers.length} selecionado${selectedUsers.length > 1 ? "s" : ""})` ? `(${selectedUsers.length} selecionado${selectedUsers.length > 1 ? 's' : ''})`
: ""} : ''}
</span> </span>
</label> </div>
</div> </div>
{/if} {/if}
<!-- Search melhorado --> <!-- Search melhorado -->
<div class="mb-4 relative"> <div class="relative mb-4">
<input <input
type="text" type="text"
placeholder="Buscar usuários por nome, email ou matrícula..." placeholder="Buscar usuários por nome, email ou matrícula..."
class="input input-bordered w-full pl-10 focus:input-primary transition-colors" class="input input-bordered focus:input-primary w-full pl-10 transition-colors"
bind:value={searchQuery} bind:value={searchQuery}
/> />
<Search <Search class="text-base-content/40 absolute top-1/2 left-3 h-5 w-5 -translate-y-1/2" />
class="w-5 h-5 absolute left-3 top-1/2 -translate-y-1/2 text-base-content/40" </div>
/>
</div>
<!-- Lista de usuários --> <!-- Lista de usuários -->
<div class="space-y-2"> <div class="space-y-2">
{#if usuarios?.data && 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)} {@const isSelected = selectedUsers.includes(usuario._id)}
<button <button
type="button" type="button"
class={`w-full text-left px-4 py-3 rounded-xl border-2 transition-all duration-200 flex items-center gap-3 ${ class={`flex w-full items-center gap-3 rounded-xl border-2 px-4 py-3 text-left transition-all duration-200 ${
isSelected isSelected
? "border-primary bg-primary/10 shadow-md scale-[1.02]" ? 'border-primary bg-primary/10 scale-[1.02] shadow-md'
: "border-base-300 hover:bg-base-200 hover:border-primary/30 hover:shadow-sm" : 'border-base-300 hover:bg-base-200 hover:border-primary/30 hover:shadow-sm'
} ${loading ? "opacity-50 cursor-not-allowed" : "cursor-pointer"}`} } ${loading ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`}
onclick={() => { onclick={() => {
if (loading) return; if (loading) return;
if (activeTab === "individual") { if (activeTab === 'individual') {
handleCriarIndividual(usuario._id); handleCriarIndividual(usuario._id);
} else { } else {
toggleUserSelection(usuario._id); toggleUserSelection(usuario._id);
} }
}} }}
disabled={loading} disabled={loading}
> >
<!-- Avatar --> <!-- Avatar -->
<div class="relative shrink-0"> <div class="relative shrink-0">
<UserAvatar <UserAvatar
avatar={usuario.avatar} avatar={usuario.avatar}
fotoPerfilUrl={usuario.fotoPerfilUrl} fotoPerfilUrl={usuario.fotoPerfilUrl}
nome={usuario.nome} nome={usuario.nome}
size="md" size="md"
/> />
<div class="absolute -bottom-1 -right-1"> <div class="absolute -right-1 -bottom-1">
<UserStatusBadge <UserStatusBadge status={usuario.statusPresenca || 'offline'} size="sm" />
status={usuario.statusPresenca || "offline"} </div>
size="sm" </div>
/>
</div>
</div>
<!-- Info --> <!-- Info -->
<div class="flex-1 min-w-0"> <div class="min-w-0 flex-1">
<p class="font-semibold text-base-content truncate"> <p class="text-base-content truncate font-semibold">
{usuario.nome} {usuario.nome}
</p> </p>
<p class="text-sm text-base-content/60 truncate"> <p class="text-base-content/60 truncate text-sm">
{usuario.setor || {usuario.setor || usuario.email || usuario.matricula || 'Sem informações'}
usuario.email || </p>
usuario.matricula || </div>
"Sem informações"}
</p>
</div>
<!-- Checkbox melhorado (para grupo e sala de reunião) --> <!-- Checkbox melhorado (para grupo e sala de reunião) -->
{#if activeTab === "grupo" || activeTab === "sala_reuniao"} {#if activeTab === 'grupo' || activeTab === 'sala_reuniao'}
<div class="shrink-0"> <div class="shrink-0">
<input <input
type="checkbox" type="checkbox"
class="checkbox checkbox-primary checkbox-lg" class="checkbox checkbox-primary checkbox-lg"
checked={isSelected} checked={isSelected}
readonly readonly
/> />
</div> </div>
{:else} {:else}
<!-- Ícone de seta para individual --> <!-- Ícone de seta para individual -->
<ChevronRight class="w-5 h-5 text-base-content/40" /> <ChevronRight class="text-base-content/40 h-5 w-5" />
{/if} {/if}
</button> </button>
{/each} {/each}
{:else if !usuarios?.data} {:else if !usuarios?.data}
<div class="flex flex-col items-center justify-center py-12"> <div class="flex flex-col items-center justify-center py-12">
<span class="loading loading-spinner loading-lg text-primary" <span class="loading loading-spinner loading-lg text-primary"></span>
></span> <p class="text-base-content/60 mt-4">Carregando usuários...</p>
<p class="mt-4 text-base-content/60">Carregando usuários...</p> </div>
</div> {:else}
{:else} <div class="flex flex-col items-center justify-center py-12 text-center">
<div <UserX class="text-base-content/30 mb-4 h-16 w-16" />
class="flex flex-col items-center justify-center py-12 text-center" <p class="text-base-content/70 font-medium">
> {searchQuery.trim() ? 'Nenhum usuário encontrado' : 'Nenhum usuário disponível'}
<UserX class="w-16 h-16 text-base-content/30 mb-4" /> </p>
<p class="text-base-content/70 font-medium"> {#if searchQuery.trim()}
{searchQuery.trim() <p class="text-base-content/50 mt-2 text-sm">
? "Nenhum usuário encontrado" Tente buscar por nome, email ou matrícula
: "Nenhum usuário disponível"} </p>
</p> {/if}
{#if searchQuery.trim()} </div>
<p class="text-sm text-base-content/50 mt-2"> {/if}
Tente buscar por nome, email ou matrícula </div>
</p> </div>
{/if}
</div>
{/if}
</div>
</div>
<!-- Footer (para grupo e sala de reunião) --> <!-- 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 bg-base-200/50"> <div class="border-base-300 bg-base-200/50 border-t px-6 py-4">
<button <button
type="button" type="button"
class="btn btn-primary btn-block btn-lg font-semibold shadow-lg hover:shadow-xl transition-all duration-200" class="btn btn-primary btn-block btn-lg font-semibold shadow-lg transition-all duration-200 hover:shadow-xl"
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 grupo... Criando grupo...
{:else} {:else}
<Plus class="w-5 h-5" /> <Plus class="h-5 w-5" />
Criar Grupo Criar Grupo
{/if} {/if}
</button> </button>
{#if selectedUsers.length < 2 && activeTab === "grupo"} {#if selectedUsers.length < 2 && activeTab === 'grupo'}
<p class="text-xs text-base-content/50 text-center mt-2"> <p class="text-base-content/50 mt-2 text-center text-xs">
Selecione pelo menos 2 participantes Selecione pelo menos 2 participantes
</p> </p>
{/if} {/if}
</div> </div>
{:else if activeTab === "sala_reuniao"} {:else if activeTab === 'sala_reuniao'}
<div class="px-6 py-4 border-t border-base-300 bg-base-200/50"> <div class="border-base-300 bg-base-200/50 border-t px-6 py-4">
<button <button
type="button" type="button"
class="btn btn-primary btn-block btn-lg font-semibold shadow-lg hover:shadow-xl transition-all duration-200" class="btn btn-primary btn-block btn-lg font-semibold shadow-lg transition-all duration-200 hover:shadow-xl"
onclick={handleCriarSalaReuniao} onclick={handleCriarSalaReuniao}
disabled={loading || disabled={loading || selectedUsers.length < 1 || !salaReuniaoName.trim()}
selectedUsers.length < 1 || >
!salaReuniaoName.trim()} {#if loading}
> <span class="loading loading-spinner"></span>
{#if loading} Criando sala...
<span class="loading loading-spinner"></span> {:else}
Criando sala... <Plus class="h-5 w-5" />
{:else} Criar Sala de Reunião
<Plus class="w-5 h-5" /> {/if}
Criar Sala de Reunião </button>
{/if} {#if selectedUsers.length < 1 && activeTab === 'sala_reuniao'}
</button> <p class="text-base-content/50 mt-2 text-center text-xs">
{#if selectedUsers.length < 1 && activeTab === "sala_reuniao"} Selecione pelo menos 1 participante
<p class="text-xs text-base-content/50 text-center mt-2"> </p>
Selecione pelo menos 1 participante {/if}
</p> </div>
{/if} {/if}
</div> </div>
{/if} <form method="dialog" class="modal-backdrop">
</div> <button type="button" onclick={onClose}>fechar</button>
<form method="dialog" class="modal-backdrop"> </form>
<button type="button" onclick={onClose}>fechar</button>
</form>
</dialog> </dialog>

View File

@@ -1,502 +1,481 @@
<script lang="ts"> <script lang="ts">
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 type { Id } from "@sgse-app/backend/convex/_generated/dataModel"; import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
let { onClose }: { onClose: () => void } = $props(); let { onClose }: { onClose: () => void } = $props();
const client = useConvexClient(); const client = useConvexClient();
const alertasQuery = useQuery(api.monitoramento.listarAlertas, {}); const alertas = useQuery(api.monitoramento.listarAlertas, {});
const alertas = $derived.by(() => {
if (!alertasQuery) return [];
// O useQuery pode retornar o array diretamente ou em .data
if (Array.isArray(alertasQuery)) return alertasQuery;
return alertasQuery.data ?? [];
});
// Estado para novo alerta $inspect(alertas);
let editingAlertId = $state<Id<"alertConfigurations"> | null>(null);
let metricName = $state("cpuUsage");
let threshold = $state(80);
let operator = $state<">" | "<" | ">=" | "<=" | "==">(">");
let enabled = $state(true);
let notifyByEmail = $state(false);
let notifyByChat = $state(true);
let saving = $state(false);
let showForm = $state(false);
const metricOptions = [ // Estado para novo alerta
{ value: "cpuUsage", label: "Uso de CPU (%)" }, let editingAlertId = $state<Id<'alertConfigurations'> | null>(null);
{ value: "memoryUsage", label: "Uso de Memória (%)" }, let metricName = $state('cpuUsage');
{ value: "networkLatency", label: "Latência de Rede (ms)" }, let threshold = $state(80);
{ value: "storageUsed", label: "Armazenamento Usado (%)" }, let operator = $state<'>' | '<' | '>=' | '<=' | '=='>('>');
{ value: "usuariosOnline", label: "Usuários Online" }, let enabled = $state(true);
{ value: "mensagensPorMinuto", label: "Mensagens por Minuto" }, let notifyByEmail = $state(false);
{ value: "tempoRespostaMedio", label: "Tempo de Resposta (ms)" }, let notifyByChat = $state(true);
{ value: "errosCount", label: "Contagem de Erros" }, let saving = $state(false);
]; let showForm = $state(false);
const operatorOptions = [ const metricOptions = [
{ value: ">", label: "Maior que (>)" }, { value: 'cpuUsage', label: 'Uso de CPU (%)' },
{ value: ">=", label: "Maior ou igual (≥)" }, { value: 'memoryUsage', label: 'Uso de Memória (%)' },
{ value: "<", label: "Menor que (<)" }, { value: 'networkLatency', label: 'Latência de Rede (ms)' },
{ value: "<=", label: "Menor ou igual (≤)" }, { value: 'storageUsed', label: 'Armazenamento Usado (%)' },
{ value: "==", label: "Igual a (=)" }, { value: 'usuariosOnline', label: 'Usuários Online' },
]; { value: 'mensagensPorMinuto', label: 'Mensagens por Minuto' },
{ value: 'tempoRespostaMedio', label: 'Tempo de Resposta (ms)' },
{ value: 'errosCount', label: 'Contagem de Erros' }
];
function resetForm() { const operatorOptions = [
editingAlertId = null; { value: '>', label: 'Maior que (>)' },
metricName = "cpuUsage"; { value: '>=', label: 'Maior ou igual (≥)' },
threshold = 80; { value: '<', label: 'Menor que (<)' },
operator = ">"; { value: '<=', label: 'Menor ou igual (≤)' },
enabled = true; { value: '==', label: 'Igual a (=)' }
notifyByEmail = false; ];
notifyByChat = true;
showForm = false;
}
function editAlert(alert: any) { function resetForm() {
editingAlertId = alert._id; editingAlertId = null;
metricName = alert.metricName; metricName = 'cpuUsage';
threshold = alert.threshold; threshold = 80;
operator = alert.operator; operator = '>';
enabled = alert.enabled; enabled = true;
notifyByEmail = alert.notifyByEmail; notifyByEmail = false;
notifyByChat = alert.notifyByChat; notifyByChat = true;
showForm = true; showForm = false;
} }
async function saveAlert() { function editAlert(alert: any) {
saving = true; editingAlertId = alert._id;
try { metricName = alert.metricName;
await client.mutation(api.monitoramento.configurarAlerta, { threshold = alert.threshold;
alertId: editingAlertId || undefined, operator = alert.operator;
metricName, enabled = alert.enabled;
threshold, notifyByEmail = alert.notifyByEmail;
operator, notifyByChat = alert.notifyByChat;
enabled, showForm = true;
notifyByEmail, }
notifyByChat,
});
resetForm(); async function saveAlert() {
} catch (error) { saving = true;
console.error("Erro ao salvar alerta:", error); try {
alert("Erro ao salvar alerta. Tente novamente."); await client.mutation(api.monitoramento.configurarAlerta, {
} finally { alertId: editingAlertId || undefined,
saving = false; metricName,
} threshold,
} operator,
enabled,
notifyByEmail,
notifyByChat
});
async function deleteAlert(alertId: Id<"alertConfigurations">) { resetForm();
if (!confirm("Tem certeza que deseja deletar este alerta?")) return; } catch (error) {
console.error('Erro ao salvar alerta:', error);
alert('Erro ao salvar alerta. Tente novamente.');
} finally {
saving = false;
}
}
try { async function deleteAlert(alertId: Id<'alertConfigurations'>) {
await client.mutation(api.monitoramento.deletarAlerta, { alertId }); if (!confirm('Tem certeza que deseja deletar este alerta?')) return;
} catch (error) {
console.error("Erro ao deletar alerta:", error);
alert("Erro ao deletar alerta. Tente novamente.");
}
}
function getMetricLabel(metricName: string): string { try {
return ( await client.mutation(api.monitoramento.deletarAlerta, { alertId });
metricOptions.find((m) => m.value === metricName)?.label || metricName } catch (error) {
); console.error('Erro ao deletar alerta:', error);
} alert('Erro ao deletar alerta. Tente novamente.');
}
}
function getOperatorLabel(op: string): string { function getMetricLabel(metricName: string): string {
return operatorOptions.find((o) => o.value === op)?.label || op; return metricOptions.find((m) => m.value === metricName)?.label || metricName;
} }
function getOperatorLabel(op: string): string {
return operatorOptions.find((o) => o.value === op)?.label || op;
}
</script> </script>
<dialog class="modal modal-open"> <dialog class="modal modal-open">
<div class="modal-box max-w-4xl bg-linear-to-br from-base-100 to-base-200"> <div class="modal-box from-base-100 to-base-200 max-w-4xl bg-linear-to-br">
<button <button
type="button" type="button"
class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" class="btn btn-sm btn-circle btn-ghost absolute top-2 right-2"
onclick={onClose} onclick={onClose}
> >
</button> </button>
<h3 class="font-bold text-3xl text-primary mb-2"> <h3 class="text-primary mb-2 text-3xl font-bold">⚙️ Configuração de Alertas</h3>
⚙️ Configuração de Alertas <p class="text-base-content/60 mb-6">
</h3> Configure alertas personalizados para monitoramento do sistema
<p class="text-base-content/60 mb-6"> </p>
Configure alertas personalizados para monitoramento do sistema
</p>
<!-- Botão Novo Alerta --> <!-- Botão Novo Alerta -->
{#if !showForm} {#if !showForm}
<button <button type="button" class="btn btn-primary mb-6" onclick={() => (showForm = true)}>
type="button" <svg
class="btn btn-primary mb-6" xmlns="http://www.w3.org/2000/svg"
onclick={() => (showForm = true)} class="h-5 w-5"
> fill="none"
<svg viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg" stroke="currentColor"
class="h-5 w-5" >
fill="none" <path
viewBox="0 0 24 24" stroke-linecap="round"
stroke="currentColor" stroke-linejoin="round"
> stroke-width="2"
<path d="M12 4v16m8-8H4"
stroke-linecap="round" />
stroke-linejoin="round" </svg>
stroke-width="2" Novo Alerta
d="M12 4v16m8-8H4" </button>
/> {/if}
</svg>
Novo Alerta
</button>
{/if}
<!-- Formulário de Alerta --> <!-- Formulário de Alerta -->
{#if showForm} {#if showForm}
<div class="card bg-base-100 shadow-xl mb-6 border-2 border-primary/20"> <div class="card bg-base-100 border-primary/20 mb-6 border-2 shadow-xl">
<div class="card-body"> <div class="card-body">
<h4 class="card-title text-xl"> <h4 class="card-title text-xl">
{editingAlertId ? "Editar Alerta" : "Novo Alerta"} {editingAlertId ? 'Editar Alerta' : 'Novo Alerta'}
</h4> </h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4"> <div class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
<!-- Métrica --> <!-- Métrica -->
<div class="form-control"> <div class="form-control">
<label class="label" for="metric"> <label class="label" for="metric">
<span class="label-text font-semibold">Métrica</span> <span class="label-text font-semibold">Métrica</span>
</label> </label>
<select <select
id="metric" id="metric"
class="select select-bordered select-primary" class="select select-bordered select-primary"
bind:value={metricName} bind:value={metricName}
> >
{#each metricOptions as option} {#each metricOptions as option (option.value)}
<option value={option.value}>{option.label}</option> <option value={option.value}>{option.label}</option>
{/each} {/each}
</select> </select>
</div> </div>
<!-- Operador --> <!-- Operador -->
<div class="form-control"> <div class="form-control">
<label class="label" for="operator"> <label class="label" for="operator">
<span class="label-text font-semibold">Condição</span> <span class="label-text font-semibold">Condição</span>
</label> </label>
<select <select
id="operator" id="operator"
class="select select-bordered select-primary" class="select select-bordered select-primary"
bind:value={operator} bind:value={operator}
> >
{#each operatorOptions as option} {#each operatorOptions as option (option.value)}
<option value={option.value}>{option.label}</option> <option value={option.value}>{option.label}</option>
{/each} {/each}
</select> </select>
</div> </div>
<!-- Threshold --> <!-- Threshold -->
<div class="form-control"> <div class="form-control">
<label class="label" for="threshold"> <label class="label" for="threshold">
<span class="label-text font-semibold">Valor Limite</span> <span class="label-text font-semibold">Valor Limite</span>
</label> </label>
<input <input
id="threshold" id="threshold"
type="number" type="number"
class="input input-bordered input-primary" class="input input-bordered input-primary"
bind:value={threshold} bind:value={threshold}
min="0" min="0"
step="1" step="1"
/> />
</div> </div>
<!-- Ativo --> <!-- Ativo -->
<div class="form-control"> <div class="form-control">
<label class="label cursor-pointer justify-start gap-4"> <label class="label cursor-pointer justify-start gap-4">
<span class="label-text font-semibold">Alerta Ativo</span> <span class="label-text font-semibold">Alerta Ativo</span>
<input <input type="checkbox" class="toggle toggle-primary" bind:checked={enabled} />
type="checkbox" </label>
class="toggle toggle-primary" </div>
bind:checked={enabled} </div>
/>
</label>
</div>
</div>
<!-- Notificações --> <!-- Notificações -->
<div class="divider">Método de Notificação</div> <div class="divider">Método de Notificação</div>
<div class="flex gap-6"> <div class="flex gap-6">
<label class="label cursor-pointer gap-3"> <label class="label cursor-pointer gap-3">
<input <input
type="checkbox" type="checkbox"
class="checkbox checkbox-primary" class="checkbox checkbox-primary"
bind:checked={notifyByChat} bind:checked={notifyByChat}
/> />
<span class="label-text"> <span class="label-text">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 inline mr-2" class="mr-2 inline h-5 w-5"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
> >
<path <path
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
stroke-width="2" stroke-width="2"
d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"
/> />
</svg> </svg>
Notificar por Chat Notificar por Chat
</span> </span>
</label> </label>
<label class="label cursor-pointer gap-3"> <label class="label cursor-pointer gap-3">
<input <input
type="checkbox" type="checkbox"
class="checkbox checkbox-secondary" class="checkbox checkbox-secondary"
bind:checked={notifyByEmail} bind:checked={notifyByEmail}
/> />
<span class="label-text"> <span class="label-text">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 inline mr-2" class="mr-2 inline h-5 w-5"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
> >
<path <path
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
stroke-width="2" stroke-width="2"
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/> />
</svg> </svg>
Notificar por E-mail Notificar por E-mail
</span> </span>
</label> </label>
</div> </div>
<!-- Preview --> <!-- Preview -->
<div class="alert alert-info mt-4"> <div class="alert alert-info mt-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"
class="stroke-current shrink-0 w-6 h-6" class="h-6 w-6 shrink-0 stroke-current"
> >
<path <path
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
stroke-width="2" stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path> ></path>
</svg> </svg>
<div> <div>
<h4 class="font-bold">Preview do Alerta:</h4> <h4 class="font-bold">Preview do Alerta:</h4>
<p class="text-sm"> <p class="text-sm">
Alertar quando <strong>{getMetricLabel(metricName)}</strong> for Alertar quando <strong>{getMetricLabel(metricName)}</strong> for
<strong>{getOperatorLabel(operator)}</strong> a <strong>{getOperatorLabel(operator)}</strong> a
<strong>{threshold}</strong> <strong>{threshold}</strong>
</p> </p>
</div> </div>
</div> </div>
<!-- Botões --> <!-- Botões -->
<div class="card-actions justify-end mt-4"> <div class="card-actions mt-4 justify-end">
<button <button type="button" class="btn btn-ghost" onclick={resetForm} disabled={saving}>
type="button" Cancelar
class="btn btn-ghost" </button>
onclick={resetForm} <button
disabled={saving} type="button"
> class="btn btn-primary"
Cancelar onclick={saveAlert}
</button> disabled={saving || (!notifyByChat && !notifyByEmail)}
<button >
type="button" {#if saving}
class="btn btn-primary" <span class="loading loading-spinner"></span>
onclick={saveAlert} Salvando...
disabled={saving || (!notifyByChat && !notifyByEmail)} {:else}
> <svg
{#if saving} xmlns="http://www.w3.org/2000/svg"
<span class="loading loading-spinner"></span> class="h-5 w-5"
Salvando... fill="none"
{:else} viewBox="0 0 24 24"
<svg stroke="currentColor"
xmlns="http://www.w3.org/2000/svg" >
class="h-5 w-5" <path
fill="none" stroke-linecap="round"
viewBox="0 0 24 24" stroke-linejoin="round"
stroke="currentColor" stroke-width="2"
> d="M5 13l4 4L19 7"
<path />
stroke-linecap="round" </svg>
stroke-linejoin="round" Salvar Alerta
stroke-width="2" {/if}
d="M5 13l4 4L19 7" </button>
/> </div>
</svg> </div>
Salvar Alerta </div>
{/if} {/if}
</button>
</div>
</div>
</div>
{/if}
<!-- Lista de Alertas --> <!-- Lista de Alertas -->
<div class="divider">Alertas Configurados</div> <div class="divider">Alertas Configurados</div>
{#if alertas.length > 0} {#if alertas.data && alertas.data.length > 0}
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="table table-zebra"> <table class="table-zebra table">
<thead> <thead>
<tr> <tr>
<th>Métrica</th> <th>Métrica</th>
<th>Condição</th> <th>Condição</th>
<th>Status</th> <th>Status</th>
<th>Notificações</th> <th>Notificações</th>
<th>Ações</th> <th>Ações</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each alertas as alerta} {#each alertas.data as alerta (alerta._id)}
<tr class={!alerta.enabled ? "opacity-50" : ""}> <tr class={!alerta.enabled ? 'opacity-50' : ''}>
<td> <td>
<div class="font-semibold"> <div class="font-semibold">
{getMetricLabel(alerta.metricName)} {getMetricLabel(alerta.metricName)}
</div> </div>
</td> </td>
<td> <td>
<div class="badge badge-outline"> <div class="badge badge-outline">
{getOperatorLabel(alerta.operator)} {getOperatorLabel(alerta.operator)}
{alerta.threshold} {alerta.threshold}
</div> </div>
</td> </td>
<td> <td>
{#if alerta.enabled} {#if alerta.enabled}
<div class="badge badge-success gap-2"> <div class="badge badge-success gap-2">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
class="h-3 w-3" class="h-3 w-3"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
> >
<path <path
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
stroke-width="2" stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/> />
</svg> </svg>
Ativo Ativo
</div> </div>
{:else} {:else}
<div class="badge badge-ghost gap-2"> <div class="badge badge-ghost gap-2">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
class="h-3 w-3" class="h-3 w-3"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
> >
<path <path
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
stroke-width="2" stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/> />
</svg> </svg>
Inativo Inativo
</div> </div>
{/if} {/if}
</td> </td>
<td> <td>
<div class="flex gap-1"> <div class="flex gap-1">
{#if alerta.notifyByChat} {#if alerta.notifyByChat}
<div class="badge badge-primary badge-sm">Chat</div> <div class="badge badge-primary badge-sm">Chat</div>
{/if} {/if}
{#if alerta.notifyByEmail} {#if alerta.notifyByEmail}
<div class="badge badge-secondary badge-sm">Email</div> <div class="badge badge-secondary badge-sm">Email</div>
{/if} {/if}
</div> </div>
</td> </td>
<td> <td>
<div class="flex gap-2"> <div class="flex gap-2">
<button <button
type="button" title="Editar Alerta"
class="btn btn-ghost btn-xs" type="button"
onclick={() => editAlert(alerta)} class="btn btn-ghost btn-xs"
> onclick={() => editAlert(alerta)}
<svg >
xmlns="http://www.w3.org/2000/svg" <svg
class="h-4 w-4" xmlns="http://www.w3.org/2000/svg"
fill="none" class="h-4 w-4"
viewBox="0 0 24 24" fill="none"
stroke="currentColor" viewBox="0 0 24 24"
> stroke="currentColor"
<path >
stroke-linecap="round" <path
stroke-linejoin="round" stroke-linecap="round"
stroke-width="2" stroke-linejoin="round"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" stroke-width="2"
/> d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
</svg> />
</button> </svg>
<button </button>
type="button" <button
class="btn btn-ghost btn-xs text-error" title="Deletar Alerta"
onclick={() => deleteAlert(alerta._id)} type="button"
> class="btn btn-ghost btn-xs text-error"
<svg onclick={() => deleteAlert(alerta._id)}
xmlns="http://www.w3.org/2000/svg" >
class="h-4 w-4" <svg
fill="none" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24" class="h-4 w-4"
stroke="currentColor" fill="none"
> viewBox="0 0 24 24"
<path stroke="currentColor"
stroke-linecap="round" >
stroke-linejoin="round" <path
stroke-width="2" stroke-linecap="round"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" stroke-linejoin="round"
/> stroke-width="2"
</svg> d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
</button> />
</div> </svg>
</td> </button>
</tr> </div>
{/each} </td>
</tbody> </tr>
</table> {/each}
</div> </tbody>
{:else} </table>
<div class="alert"> </div>
<svg {:else}
xmlns="http://www.w3.org/2000/svg" <div class="alert">
fill="none" <svg
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"
class="stroke-info shrink-0 w-6 h-6" fill="none"
> viewBox="0 0 24 24"
<path class="stroke-info h-6 w-6 shrink-0"
stroke-linecap="round" >
stroke-linejoin="round" <path
stroke-width="2" stroke-linecap="round"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" stroke-linejoin="round"
></path> stroke-width="2"
</svg> d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
<span ></path>
>Nenhum alerta configurado. Clique em "Novo Alerta" para criar um.</span </svg>
> <span>Nenhum alerta configurado. Clique em "Novo Alerta" para criar um.</span>
</div> </div>
{/if} {/if}
<div class="modal-action"> <div class="modal-action">
<button type="button" class="btn btn-lg" onclick={onClose}>Fechar</button> <button type="button" class="btn btn-lg" onclick={onClose}>Fechar</button>
</div> </div>
</div> </div>
<!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions --> <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<form method="dialog" class="modal-backdrop" onclick={onClose}> <form method="dialog" class="modal-backdrop" onclick={onClose}>
<button type="button">close</button> <button type="button">close</button>
</form> </form>
</dialog> </dialog>

View File

@@ -139,25 +139,6 @@ export const configurarAlerta = mutation({
*/ */
export const listarAlertas = query({ export const listarAlertas = query({
args: {}, args: {},
returns: v.array(
v.object({
_id: v.id('alertConfigurations'),
metricName: v.string(),
threshold: v.number(),
operator: v.union(
v.literal('>'),
v.literal('<'),
v.literal('>='),
v.literal('<='),
v.literal('==')
),
enabled: v.boolean(),
notifyByEmail: v.boolean(),
notifyByChat: v.boolean(),
createdBy: v.id('usuarios'),
lastModified: v.number()
})
),
handler: async (ctx) => { handler: async (ctx) => {
const alertas = await ctx.db.query('alertConfigurations').collect(); const alertas = await ctx.db.query('alertConfigurations').collect();
return alertas; return alertas;