feat: Add 'atas' (minutes/records) management feature, and implement various improvements across UI, backend logic, and authentication.

This commit is contained in:
2025-12-02 16:37:48 -03:00
parent 05e7f1181d
commit 4bd9e21748
265 changed files with 29156 additions and 26460 deletions

View File

@@ -1,514 +1,467 @@
<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 { api } from '@sgse-app/backend/convex/_generated/api';
import { useConvexClient, useQuery } from 'convex-svelte';
import { formatDistanceToNow } from 'date-fns';
import { ptBR } from 'date-fns/locale';
import { abrirConversa } from '$lib/stores/chatStore';
import NewConversationModal from './NewConversationModal.svelte';
import UserAvatar from './UserAvatar.svelte';
import UserStatusBadge from './UserStatusBadge.svelte';
const client = useConvexClient();
const client = useConvexClient();
// Buscar todos os usuários para o chat
const usuarios = useQuery(api.usuarios.listarParaChat, {});
// 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 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, {});
// Buscar conversas (grupos e salas de reunião)
const conversas = useQuery(api.chat.listarConversas, {});
let searchQuery = $state("");
let activeTab = $state<"usuarios" | "conversas">("usuarios");
const searchQuery = $state('');
const 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,
);
}
}
});
// 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 [];
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 [];
}
// 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;
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);
// 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!",
);
}
// 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),
);
}
// 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;
// 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);
});
});
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 "";
}
}
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);
let processando = $state(false);
const showNewConversationModal = $state(false);
async function handleClickUsuario(usuario: any) {
if (processando) {
console.log("⏳ Já está processando uma ação, aguarde...");
return;
}
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);
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,
},
);
// 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);
console.log('✅ Conversa criada/encontrada. ID:', conversaId);
// Abrir a conversa
console.log("📂 Abrindo conversa...");
abrirConversa(conversaId as any);
// 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;
}
}
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";
}
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 [];
// 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",
);
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));
}
// Aplicar busca
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
lista = lista.filter((c: any) => c.nome?.toLowerCase().includes(query));
}
return lista;
});
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;
}
}
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>
<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}
/>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="text-base-content/50 absolute top-1/2 left-3 h-5 w-5 -translate-y-1/2"
>
<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>
<!-- 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="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>
<!-- 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"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="mr-1 h-4 w-4"
>
<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="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>
<!-- 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}
>
<!-- Í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, 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="text-primary h-5 w-5"
>
<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 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>
<!-- Avatar -->
<div class="relative shrink-0">
<UserAvatar
avatar={usuario.avatar}
fotoPerfilUrl={usuario.fotoPerfilUrl}
nome={usuario.nome}
size="md"
/>
<!-- Status badge -->
<div class="absolute right-0 bottom-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="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-linear-to-br from-blue-500/20 to-purple-500/20 border border-blue-300/30'
: 'bg-linear-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="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>
</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">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="text-base-content/30 mb-4 h-16 w-16"
>
<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="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'}
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="h-5 w-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="text-primary h-5 w-5"
>
<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>
<!-- 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">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="text-base-content/30 mb-4 h-16 w-16"
>
<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 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)} />
<NewConversationModal onClose={() => (showNewConversationModal = false)} />
{/if}

View File

@@ -1,22 +1,21 @@
<script lang="ts">
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { useQuery } from 'convex-svelte';
import { SvelteSet } from 'svelte/reactivity';
import {
abrirChat,
abrirConversa,
chatAberto,
chatMinimizado,
conversaAtiva,
fecharChat,
minimizarChat,
maximizarChat,
abrirChat,
abrirConversa
minimizarChat
} from '$lib/stores/chatStore';
import { useQuery } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import ChatList from './ChatList.svelte';
import ChatWindow from './ChatWindow.svelte';
import { SvelteSet } from 'svelte/reactivity';
const count = useQuery(api.chat.contarNotificacoesNaoLidas, {});
// Query para verificar o ID do usuário logado (usar como referência)
@@ -48,7 +47,7 @@
let isDragging = $state(false);
let dragStart = $state({ x: 0, y: 0 });
let isAnimating = $state(false);
let dragThreshold = $state(5); // Distância mínima em pixels para considerar arrastar
const dragThreshold = $state(5); // Distância mínima em pixels para considerar arrastar
let hasMoved = $state(false); // Flag para verificar se houve movimento durante o arrastar
let shouldPreventClick = $state(false); // Flag para prevenir clique após arrastar
@@ -279,7 +278,7 @@
}
// Garantir que X também está dentro dos limites
let newX = Math.max(minX, Math.min(maxX, position.x));
const newX = Math.max(minX, Math.min(maxX, position.x));
// Aplicar novos valores apenas se necessário
if (newX !== position.x || newY !== position.y) {
@@ -807,7 +806,6 @@
style="background: conic-gradient(from 0deg, transparent 0%, rgba(255,255,255,0.3) 50%, transparent 100%); animation: rotate 3s linear infinite;"
></div>
<!-- Ícone de chat moderno com efeito 3D -->
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -820,7 +818,9 @@
class="relative z-10 h-7 w-7 text-white transition-all duration-500 group-hover:scale-110"
style="filter: drop-shadow(0 4px 12px rgba(0,0,0,0.4));"
>
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>
<path
d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"
/>
</svg>
<!-- Badge ULTRA PREMIUM com gradiente e brilho -->

View File

@@ -1,31 +1,31 @@
<script lang="ts">
import { useQuery, useConvexClient } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import { voltarParaLista } from '$lib/stores/chatStore';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import MessageList from './MessageList.svelte';
import MessageInput from './MessageInput.svelte';
import UserStatusBadge from './UserStatusBadge.svelte';
import UserAvatar from './UserAvatar.svelte';
import ScheduleMessageModal from './ScheduleMessageModal.svelte';
import SalaReuniaoManager from './SalaReuniaoManager.svelte';
import CallWindow from '../call/CallWindow.svelte';
import ErrorModal from '../ErrorModal.svelte';
//import { getAvatarUrl } from '$lib/utils/avatarGenerator';
import { browser } from '$app/environment';
import { traduzirErro } from '$lib/utils/erroHelpers';
import { useConvexClient, useQuery } from 'convex-svelte';
import {
Bell,
X,
ArrowLeft,
Bell,
Clock,
LogOut,
MoreVertical,
Users,
Clock,
XCircle,
Phone,
Video
Users,
Video,
X,
XCircle
} from 'lucide-svelte';
//import { getAvatarUrl } from '$lib/utils/avatarGenerator';
import { browser } from '$app/environment';
import { voltarParaLista } from '$lib/stores/chatStore';
import { traduzirErro } from '$lib/utils/erroHelpers';
import CallWindow from '../call/CallWindow.svelte';
import ErrorModal from '../ErrorModal.svelte';
import MessageInput from './MessageInput.svelte';
import MessageList from './MessageList.svelte';
import SalaReuniaoManager from './SalaReuniaoManager.svelte';
import ScheduleMessageModal from './ScheduleMessageModal.svelte';
import UserAvatar from './UserAvatar.svelte';
import UserStatusBadge from './UserStatusBadge.svelte';
//import { Bell, X, ArrowLeft, LogOut, MoreVertical, Users, Clock, XCircle } from 'lucide-svelte';
@@ -33,16 +33,16 @@
conversaId: string;
}
let { conversaId }: Props = $props();
const { conversaId }: Props = $props();
const client = useConvexClient();
// Token é passado automaticamente via interceptadores em +layout.svelte
let showScheduleModal = $state(false);
let showSalaManager = $state(false);
let showAdminMenu = $state(false);
let showNotificacaoModal = $state(false);
const showScheduleModal = $state(false);
const showSalaManager = $state(false);
const showAdminMenu = $state(false);
const showNotificacaoModal = $state(false);
let iniciandoChamada = $state(false);
let chamadaAtiva = $state<Id<'chamadas'> | null>(null);
@@ -145,7 +145,10 @@
}
// Funções para chamadas
async function iniciarChamada(tipo: 'audio' | 'video', abrirEmNovaJanela: boolean = false): Promise<void> {
async function iniciarChamada(
tipo: 'audio' | 'video',
abrirEmNovaJanela: boolean = false
): Promise<void> {
if (chamadaAtual) {
errorTitle = 'Chamada já em andamento';
errorMessage =
@@ -167,19 +170,24 @@
// Se deve abrir em nova janela
if (abrirEmNovaJanela && browser) {
const { abrirCallWindowEmPopup, verificarSuportePopup } = await import('$lib/utils/callWindowManager');
const { abrirCallWindowEmPopup, verificarSuportePopup } = await import(
'$lib/utils/callWindowManager'
);
if (!verificarSuportePopup()) {
errorTitle = 'Popups bloqueados';
errorMessage = 'Seu navegador está bloqueando popups. Por favor, permita popups para este site e tente novamente.';
errorMessage =
'Seu navegador está bloqueando popups. Por favor, permita popups para este site e tente novamente.';
errorInstructions = 'Verifique as configurações do seu navegador para permitir popups.';
showErrorModal = true;
return;
}
// Buscar informações da chamada para obter roomName
const chamadaInfo = await client.query(api.chamadas.obterChamada, { chamadaId });
const chamadaInfo = await client.query(api.chamadas.obterChamada, {
chamadaId
});
if (!chamadaInfo) {
throw new Error('Chamada não encontrada');
}
@@ -372,7 +380,7 @@
</button>
<ul
tabindex="0"
class="dropdown-content menu bg-base-100 rounded-box z-[100] w-52 p-2 shadow-lg border border-base-300"
class="dropdown-content menu bg-base-100 rounded-box border-base-300 z-[100] w-52 border p-2 shadow-lg"
>
<li>
<button
@@ -418,7 +426,7 @@
</button>
<ul
tabindex="0"
class="dropdown-content menu bg-base-100 rounded-box z-[100] w-52 p-2 shadow-lg border border-base-300"
class="dropdown-content menu bg-base-100 rounded-box border-base-300 z-[100] w-52 border p-2 shadow-lg"
>
<li>
<button

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,26 +1,26 @@
<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 UserStatusBadge from './UserStatusBadge.svelte';
import UserAvatar from './UserAvatar.svelte';
import { useConvexClient, useQuery } from 'convex-svelte';
import {
ChevronRight,
MessageSquare,
Plus,
Search,
User,
Users,
UserX,
Video,
X,
Search,
ChevronRight,
Plus,
UserX
X
} from 'lucide-svelte';
import { abrirConversa } from '$lib/stores/chatStore';
import UserAvatar from './UserAvatar.svelte';
import UserStatusBadge from './UserStatusBadge.svelte';
interface Props {
onClose: () => void;
}
let { onClose }: Props = $props();
const { onClose }: Props = $props();
const client = useConvexClient();
const usuarios = useQuery(api.usuarios.listarParaChat, {});
@@ -28,11 +28,11 @@
// Usuário atual
const currentUser = useQuery(api.auth.getCurrentUser, {});
let activeTab = $state<'individual' | 'grupo' | 'sala_reuniao'>('individual');
let searchQuery = $state('');
const activeTab = $state<'individual' | 'grupo' | 'sala_reuniao'>('individual');
const searchQuery = $state('');
let selectedUsers = $state<string[]>([]);
let groupName = $state('');
let salaReuniaoName = $state('');
const groupName = $state('');
const salaReuniaoName = $state('');
let loading = $state(false);
const usuariosFiltrados = $derived(() => {

File diff suppressed because it is too large Load Diff

View File

@@ -1,114 +1,120 @@
<script lang="ts">
import { useConvexClient } from "convex-svelte";
import { useQuery } from "convex-svelte";
import { api } from "@sgse-app/backend/convex/_generated/api";
import { onMount } from "svelte";
import { api } from '@sgse-app/backend/convex/_generated/api';
import { useConvexClient, useQuery } from 'convex-svelte';
import { onMount } from 'svelte';
const client = useConvexClient();
// Verificar se o usuário está autenticado antes de gerenciar presença
const currentUser = useQuery(api.auth.getCurrentUser, {});
const usuarioAutenticado = $derived(currentUser?.data !== null && currentUser?.data !== undefined);
const client = useConvexClient();
// Token é passado automaticamente via interceptadores em +layout.svelte
// Verificar se o usuário está autenticado antes de gerenciar presença
const currentUser = useQuery(api.auth.getCurrentUser, {});
const usuarioAutenticado = $derived(
currentUser?.data !== null && currentUser?.data !== undefined
);
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
let inactivityTimeout: ReturnType<typeof setTimeout> | null = null;
let lastActivity = Date.now();
// Token é passado automaticamente via interceptadores em +layout.svelte
// Detectar atividade do usuário
function handleActivity() {
if (!usuarioAutenticado) return;
lastActivity = Date.now();
// Limpar timeout de inatividade anterior
if (inactivityTimeout) {
clearTimeout(inactivityTimeout);
}
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
let inactivityTimeout: ReturnType<typeof setTimeout> | null = null;
let lastActivity = Date.now();
// Configurar novo timeout (5 minutos)
inactivityTimeout = setTimeout(() => {
if (usuarioAutenticado) {
client.mutation(api.chat.atualizarStatusPresenca, { status: "ausente" });
}
}, 5 * 60 * 1000);
}
// Detectar atividade do usuário
function handleActivity() {
if (!usuarioAutenticado) return;
onMount(() => {
// Só configurar presença se usuário estiver autenticado
if (!usuarioAutenticado) return;
lastActivity = Date.now();
// Configurar como online ao montar (apenas se autenticado)
client.mutation(api.chat.atualizarStatusPresenca, { status: "online" });
// Limpar timeout de inatividade anterior
if (inactivityTimeout) {
clearTimeout(inactivityTimeout);
}
// Heartbeat a cada 30 segundos (apenas se autenticado)
heartbeatInterval = setInterval(() => {
if (!usuarioAutenticado) {
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
heartbeatInterval = null;
}
return;
}
const timeSinceLastActivity = Date.now() - lastActivity;
// Se houve atividade nos últimos 5 minutos, manter online
if (timeSinceLastActivity < 5 * 60 * 1000) {
client.mutation(api.chat.atualizarStatusPresenca, { status: "online" });
}
}, 30 * 1000);
// Configurar novo timeout (5 minutos)
inactivityTimeout = setTimeout(
() => {
if (usuarioAutenticado) {
client.mutation(api.chat.atualizarStatusPresenca, {
status: 'ausente'
});
}
},
5 * 60 * 1000
);
}
// Listeners para detectar atividade
const events = ["mousedown", "keydown", "scroll", "touchstart"];
events.forEach((event) => {
window.addEventListener(event, handleActivity);
});
onMount(() => {
// Só configurar presença se usuário estiver autenticado
if (!usuarioAutenticado) return;
// Configurar timeout inicial de inatividade
if (usuarioAutenticado) {
handleActivity();
}
// Configurar como online ao montar (apenas se autenticado)
client.mutation(api.chat.atualizarStatusPresenca, { status: 'online' });
// Detectar quando a aba fica inativa/ativa
function handleVisibilityChange() {
if (!usuarioAutenticado) return;
if (document.hidden) {
// Aba ficou inativa
client.mutation(api.chat.atualizarStatusPresenca, { status: "ausente" });
} else {
// Aba ficou ativa
client.mutation(api.chat.atualizarStatusPresenca, { status: "online" });
handleActivity();
}
}
// Heartbeat a cada 30 segundos (apenas se autenticado)
heartbeatInterval = setInterval(() => {
if (!usuarioAutenticado) {
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
heartbeatInterval = null;
}
return;
}
document.addEventListener("visibilitychange", handleVisibilityChange);
const timeSinceLastActivity = Date.now() - lastActivity;
// Cleanup
return () => {
// Marcar como offline ao desmontar (apenas se autenticado)
if (usuarioAutenticado) {
client.mutation(api.chat.atualizarStatusPresenca, { status: "offline" });
}
// Se houve atividade nos últimos 5 minutos, manter online
if (timeSinceLastActivity < 5 * 60 * 1000) {
client.mutation(api.chat.atualizarStatusPresenca, { status: 'online' });
}
}, 30 * 1000);
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
}
// Listeners para detectar atividade
const events = ['mousedown', 'keydown', 'scroll', 'touchstart'];
events.forEach((event) => {
window.addEventListener(event, handleActivity);
});
if (inactivityTimeout) {
clearTimeout(inactivityTimeout);
}
// Configurar timeout inicial de inatividade
if (usuarioAutenticado) {
handleActivity();
}
events.forEach((event) => {
window.removeEventListener(event, handleActivity);
});
// Detectar quando a aba fica inativa/ativa
function handleVisibilityChange() {
if (!usuarioAutenticado) return;
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
});
if (document.hidden) {
// Aba ficou inativa
client.mutation(api.chat.atualizarStatusPresenca, { status: 'ausente' });
} else {
// Aba ficou ativa
client.mutation(api.chat.atualizarStatusPresenca, { status: 'online' });
handleActivity();
}
}
document.addEventListener('visibilitychange', handleVisibilityChange);
// Cleanup
return () => {
// Marcar como offline ao desmontar (apenas se autenticado)
if (usuarioAutenticado) {
client.mutation(api.chat.atualizarStatusPresenca, { status: 'offline' });
}
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
}
if (inactivityTimeout) {
clearTimeout(inactivityTimeout);
}
events.forEach((event) => {
window.removeEventListener(event, handleActivity);
});
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
});
</script>
<!-- Componente invisível - apenas lógica -->

View File

@@ -1,10 +1,10 @@
<script lang="ts">
import { useQuery, useConvexClient } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { useConvexClient, useQuery } from 'convex-svelte';
import { ArrowDown, ArrowUp, Search, Trash2, UserPlus, Users, X } from 'lucide-svelte';
import UserAvatar from './UserAvatar.svelte';
import UserStatusBadge from './UserStatusBadge.svelte';
import { X, Users, UserPlus, ArrowUp, ArrowDown, Trash2, Search } from 'lucide-svelte';
interface Props {
conversaId: Id<'conversas'>;
@@ -12,13 +12,13 @@
onClose: () => void;
}
let { conversaId, isAdmin, onClose }: Props = $props();
const { conversaId, isAdmin, onClose }: Props = $props();
const client = useConvexClient();
const conversas = useQuery(api.chat.listarConversas, {});
const todosUsuariosQuery = useQuery(api.chat.listarTodosUsuarios, {});
let activeTab = $state<'participantes' | 'adicionar'>('participantes');
const activeTab = $state<'participantes' | 'adicionar'>('participantes');
let searchQuery = $state('');
let loading = $state<string | null>(null);
let error = $state<string | null>(null);

View File

@@ -1,17 +1,17 @@
<script lang="ts">
import { useQuery, useConvexClient } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { useConvexClient, useQuery } from 'convex-svelte';
import { format } from 'date-fns';
import { ptBR } from 'date-fns/locale';
import { Clock, X, Trash2 } from 'lucide-svelte';
import { Clock, Trash2, X } from 'lucide-svelte';
interface Props {
conversaId: Id<'conversas'>;
onClose: () => void;
}
let { conversaId, onClose }: Props = $props();
const { conversaId, onClose }: Props = $props();
const client = useConvexClient();
const mensagensAgendadas = useQuery(api.chat.obterMensagensAgendadas, {

View File

@@ -7,7 +7,7 @@
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
}
let { fotoPerfilUrl, nome, size = 'md' }: Props = $props();
const { fotoPerfilUrl, nome, size = 'md' }: Props = $props();
const sizeClasses = {
xs: 'w-8 h-8',

View File

@@ -1,75 +1,74 @@
<script lang="ts">
interface Props {
status?: "online" | "offline" | "ausente" | "externo" | "em_reuniao";
size?: "sm" | "md" | "lg";
}
interface Props {
status?: 'online' | 'offline' | 'ausente' | 'externo' | 'em_reuniao';
size?: 'sm' | 'md' | 'lg';
}
let { status = "offline", size = "md" }: Props = $props();
const { status = 'offline', size = 'md' }: Props = $props();
const sizeClasses = {
sm: "w-3 h-3",
md: "w-4 h-4",
lg: "w-5 h-5",
};
const sizeClasses = {
sm: 'w-3 h-3',
md: 'w-4 h-4',
lg: 'w-5 h-5'
};
const statusConfig = {
online: {
color: "bg-success",
borderColor: "border-success",
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-full h-full">
const statusConfig = {
online: {
color: 'bg-success',
borderColor: 'border-success',
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-full h-full">
<circle cx="12" cy="12" r="10" fill="#10b981"/>
<path d="M9 12l2 2 4-4" stroke="white" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`,
label: "🟢 Online",
},
offline: {
color: "bg-base-300",
borderColor: "border-base-300",
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-full h-full">
label: '🟢 Online'
},
offline: {
color: 'bg-base-300',
borderColor: 'border-base-300',
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-full h-full">
<circle cx="12" cy="12" r="10" fill="#9ca3af"/>
<path d="M8 8l8 8M16 8l-8 8" stroke="white" stroke-width="2" stroke-linecap="round"/>
</svg>`,
label: "⚫ Offline",
},
ausente: {
color: "bg-warning",
borderColor: "border-warning",
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-full h-full">
label: '⚫ Offline'
},
ausente: {
color: 'bg-warning',
borderColor: 'border-warning',
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-full h-full">
<circle cx="12" cy="12" r="10" fill="#f59e0b"/>
<circle cx="12" cy="6" r="1.5" fill="white"/>
<path d="M12 10v4" stroke="white" stroke-width="2" stroke-linecap="round"/>
</svg>`,
label: "🟡 Ausente",
},
externo: {
color: "bg-info",
borderColor: "border-info",
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-full h-full">
label: '🟡 Ausente'
},
externo: {
color: 'bg-info',
borderColor: 'border-info',
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-full h-full">
<circle cx="12" cy="12" r="10" fill="#3b82f6"/>
<path d="M8 12h8M12 8v8" stroke="white" stroke-width="2" stroke-linecap="round"/>
</svg>`,
label: "🔵 Externo",
},
em_reuniao: {
color: "bg-error",
borderColor: "border-error",
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-full h-full">
label: '🔵 Externo'
},
em_reuniao: {
color: 'bg-error',
borderColor: 'border-error',
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-full h-full">
<circle cx="12" cy="12" r="10" fill="#ef4444"/>
<rect x="8" y="8" width="8" height="8" fill="white" rx="1"/>
</svg>`,
label: "🔴 Em Reunião",
},
};
label: '🔴 Em Reunião'
}
};
const config = $derived(statusConfig[status]);
const config = $derived(statusConfig[status]);
</script>
<div
class={`${sizeClasses[size]} rounded-full relative flex items-center justify-center`}
style="box-shadow: 0 2px 8px rgba(0,0,0,0.15); border: 2px solid white;"
title={config.label}
aria-label={config.label}
class={`${sizeClasses[size]} relative flex items-center justify-center rounded-full`}
style="box-shadow: 0 2px 8px rgba(0,0,0,0.15); border: 2px solid white;"
title={config.label}
aria-label={config.label}
>
{@html config.icon}
{@html config.icon}
</div>