Revert "Feat many fixes"

This commit is contained in:
Kilder Costa
2025-11-11 16:41:40 -03:00
committed by GitHub
parent 1c197a7534
commit caff7035f7
51 changed files with 20765 additions and 22636 deletions

View File

@@ -1,448 +1,514 @@
<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 NewConversationModal from './NewConversationModal.svelte';
import type { Doc, Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { useQuery, useConvexClient } from "convex-svelte";
import { api } from "@sgse-app/backend/convex/_generated/api";
import { abrirConversa } from "$lib/stores/chatStore";
import { formatDistanceToNow } from "date-fns";
import { ptBR } from "date-fns/locale";
import UserStatusBadge from "./UserStatusBadge.svelte";
import UserAvatar from "./UserAvatar.svelte";
import NewConversationModal from "./NewConversationModal.svelte";
const client = useConvexClient();
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');
let searchQuery = $state("");
let activeTab = $state<"usuarios" | "conversas">("usuarios");
const usuariosFiltrados = $derived.by(() => {
if (!usuarios?.data || !Array.isArray(usuarios.data)) return [];
// 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,
);
}
}
});
// 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 usuariosFiltrados = $derived.by(() => {
if (!usuarios?.data || !Array.isArray(usuarios.data)) return [];
const meuId = meuPerfil.data._id;
// 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 [];
}
// Filtrar o próprio usuário da lista (filtro de segurança no frontend)
let listaFiltrada = usuarios.data.filter((u) => u._id !== meuId);
const meuId = meuPerfil.data._id;
// Log se ainda estiver na lista após filtro (não deveria acontecer)
const aindaNaLista = listaFiltrada.find((u) => u._id === meuId);
if (aindaNaLista) {
console.error('❌ [ChatList] ERRO: Meu usuário ainda está na lista após filtro!');
}
// Filtrar o próprio usuário da lista (filtro de segurança no frontend)
let listaFiltrada = usuarios.data.filter((u: any) => u._id !== meuId);
// Aplicar busca por nome/email/matrícula
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
listaFiltrada = listaFiltrada.filter(
(u) =>
u.nome?.toLowerCase().includes(query) ||
u.email?.toLowerCase().includes(query) ||
u.matricula?.toLowerCase().includes(query)
);
}
// 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!",
);
}
// Ordenar: Online primeiro, depois por nome
return listaFiltrada.sort((a, b) => {
const statusOrder = {
online: 0,
ausente: 1,
externo: 2,
em_reuniao: 3,
offline: 4
};
const statusA = statusOrder[a.statusPresenca as keyof typeof statusOrder] ?? 4;
const statusB = statusOrder[b.statusPresenca as keyof typeof statusOrder] ?? 4;
// 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),
);
}
if (statusA !== statusB) return statusA - statusB;
return a.nome.localeCompare(b.nome);
});
});
// 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;
let processando = $state(false);
let showNewConversationModal = $state(false);
if (statusA !== statusB) return statusA - statusB;
return a.nome.localeCompare(b.nome);
});
});
async function handleClickUsuario(usuario: {
_id: Id<'usuarios'>;
nome: string;
email: string;
matricula: string | undefined;
avatar: string | undefined;
fotoPerfil: Id<'_storage'> | undefined;
fotoPerfilUrl: string | null;
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;
}
function formatarTempo(timestamp: number | undefined): string {
if (!timestamp) return "";
try {
return formatDistanceToNow(new Date(timestamp), {
addSuffix: true,
locale: ptBR,
});
} catch {
return "";
}
}
try {
processando = true;
console.log('🔄 Clicou no usuário:', usuario.nome, 'ID:', usuario._id);
let processando = $state(false);
let showNewConversationModal = $state(false);
// 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
});
async function handleClickUsuario(usuario: any) {
if (processando) {
console.log("⏳ Já está processando uma ação, aguarde...");
return;
}
console.log('✅ Conversa criada/encontrada. ID:', conversaId);
try {
processando = true;
console.log("🔄 Clicou no usuário:", usuario.nome, "ID:", usuario._id);
// Abrir a conversa
console.log('📂 Abrindo conversa...');
abrirConversa(conversaId);
// 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 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 criada/encontrada. ID:", conversaId);
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';
}
// Abrir a conversa
console.log("📂 Abrindo conversa...");
abrirConversa(conversaId as any);
// Filtrar conversas por tipo e busca
const conversasFiltradas = $derived(() => {
if (!conversas?.data) return [];
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;
}
}
let lista = conversas.data.filter((c) => c.tipo === 'grupo' || c.tipo === 'sala_reuniao');
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";
}
// Aplicar busca
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
lista = lista.filter((c) => c.nome?.toLowerCase().includes(query));
}
// Filtrar conversas por tipo e busca
const conversasFiltradas = $derived(() => {
if (!conversas?.data) return [];
return lista;
});
let lista = conversas.data.filter(
(c: any) => c.tipo === "grupo" || c.tipo === "sala_reuniao",
);
function handleClickConversa(conversa: Doc<'conversas'>) {
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;
}
}
// Aplicar busca
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
lista = lista.filter((c: any) => c.nome?.toLowerCase().includes(query));
}
return lista;
});
function handleClickConversa(conversa: any) {
if (processando) return;
try {
processando = true;
abrirConversa(conversa._id);
} catch (error) {
console.error("Erro ao abrir conversa:", error);
alert(
`Erro ao abrir conversa: ${error instanceof Error ? error.message : String(error)}`,
);
} finally {
processando = false;
}
}
</script>
<div class="flex 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>
<div class="flex flex-col h-full">
<!-- Search bar -->
<div class="p-4 border-b border-base-300">
<div class="relative">
<input
type="text"
placeholder="Buscar usuários (nome, email, matrícula)..."
class="input input-bordered w-full pl-10"
bind:value={searchQuery}
/>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-5 h-5 absolute left-3 top-1/2 -translate-y-1/2 text-base-content/50"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"
/>
</svg>
</div>
</div>
<!-- Tabs e Título -->
<div class="border-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>
<!-- Tabs e Título -->
<div class="border-b border-base-300 bg-base-200">
<!-- Tabs -->
<div class="tabs tabs-boxed p-2">
<button
type="button"
class={`tab flex-1 ${activeTab === "usuarios" ? "tab-active" : ""}`}
onclick={() => (activeTab = "usuarios")}
>
👥 Usuários ({usuariosFiltrados.length})
</button>
<button
type="button"
class={`tab flex-1 ${activeTab === "conversas" ? "tab-active" : ""}`}
onclick={() => (activeTab = "conversas")}
>
💬 Conversas ({conversasFiltradas().length})
</button>
</div>
<!-- Botão Nova Conversa -->
<div class="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>
<!-- Botão Nova Conversa -->
<div class="px-4 pb-2 flex justify-end">
<button
type="button"
class="btn btn-primary btn-sm"
onclick={() => (showNewConversationModal = true)}
title="Nova conversa (grupo ou sala de reunião)"
aria-label="Nova conversa"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="w-4 h-4 mr-1"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 4.5v15m7.5-7.5h-15"
/>
</svg>
Nova Conversa
</button>
</div>
</div>
<!-- Lista de conteúdo -->
<div class="flex-1 overflow-y-auto">
{#if activeTab === 'usuarios'}
<!-- Lista de usuários -->
{#if usuarios?.data && usuariosFiltrados.length > 0}
{#each usuariosFiltrados as usuario (usuario._id)}
<button
type="button"
class="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>
<!-- 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>
<!-- 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>
<!-- 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>
<!-- 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">
{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">
{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>
<!-- Conteúdo -->
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between mb-1">
<p class="font-semibold text-base-content truncate">
{conversa.nome ||
(conversa.tipo === "sala_reuniao"
? "Sala sem nome"
: "Grupo sem nome")}
</p>
{#if conversa.naoLidas > 0}
<span class="badge badge-primary badge-sm"
>{conversa.naoLidas}</span
>
{/if}
</div>
<div class="flex items-center gap-2">
<span
class="text-xs px-2 py-0.5 rounded-full {conversa.tipo ===
'sala_reuniao'
? 'bg-blue-500/20 text-blue-500'
: 'bg-primary/20 text-primary'}"
>
{conversa.tipo === "sala_reuniao"
? "👑 Sala de Reunião"
: "👥 Grupo"}
</span>
{#if conversa.participantesInfo}
<span class="text-xs text-base-content/50">
{conversa.participantesInfo.length} participante{conversa
.participantesInfo.length !== 1
? "s"
: ""}
</span>
{/if}
</div>
</div>
</button>
{/each}
{:else if !conversas?.data}
<!-- Loading -->
<div class="flex items-center justify-center h-full">
<span class="loading loading-spinner loading-lg"></span>
</div>
{:else}
<!-- Nenhuma conversa encontrada -->
<div
class="flex flex-col items-center justify-center h-full text-center px-4"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-16 h-16 text-base-content/30 mb-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 0 1-.825-.242m9.345-8.334a2.126 2.126 0 0 0-.476-.095 48.64 48.64 0 0 0-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0 0 11.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155"
/>
</svg>
<p class="text-base-content/70 font-medium mb-2">
Nenhuma conversa encontrada
</p>
<p class="text-sm text-base-content/50">
Crie um grupo ou sala de reunião para começar
</p>
</div>
{/if}
{/if}
</div>
</div>
<!-- Modal de Nova Conversa -->
{#if showNewConversationModal}
<NewConversationModal onClose={() => (showNewConversationModal = false)} />
<NewConversationModal onClose={() => (showNewConversationModal = false)} />
{/if}

View File

@@ -34,8 +34,8 @@
if (!usuario) return null;
// Prioridade: fotoPerfilUrl > avatar > fallback com nome
if (usuario.fotoPerfilUrl) {
return usuario.fotoPerfilUrl;
if (usuario.fotoPerfil) {
return usuario.fotoPerfil;
}
if (usuario.avatar) {
return getAvatarUrl(usuario.avatar);
@@ -768,14 +768,14 @@
type="button"
class="group fixed border-0 backdrop-blur-xl"
style="
z-index: 99999 !important;
width: 4.5rem;
height: 4.5rem;
z-index: 99999 !important;
width: 4.5rem;
height: 4.5rem;
bottom: {bottomPos};
right: {rightPos};
position: fixed !important;
background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%);
box-shadow:
box-shadow:
0 20px 60px -10px rgba(102, 126, 234, 0.5),
0 10px 30px -5px rgba(118, 75, 162, 0.4),
0 0 0 1px rgba(255, 255, 255, 0.1) inset;
@@ -839,7 +839,7 @@
class="absolute -top-1 -right-1 z-20 flex h-8 w-8 items-center justify-center rounded-full text-xs font-black text-white"
style="
background: linear-gradient(135deg, #ff416c, #ff4b2b);
box-shadow:
box-shadow:
0 8px 24px -4px rgba(255, 65, 108, 0.6),
0 4px 12px -2px rgba(255, 75, 43, 0.4),
0 0 0 3px rgba(255, 255, 255, 0.3),
@@ -883,7 +883,7 @@
position: fixed !important;
background: linear-gradient(135deg, rgba(255,255,255,0.95) 0%, rgba(249,250,251,0.98) 100%);
border-radius: 24px;
box-shadow:
box-shadow:
0 32px 64px -12px rgba(0, 0, 0, 0.15),
0 16px 32px -8px rgba(0, 0, 0, 0.1),
0 0 0 1px rgba(0, 0, 0, 0.05),

View File

@@ -1,495 +1,545 @@
<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 { getAvatarUrl } from '$lib/utils/avatarGenerator';
import { Bell, X, ArrowLeft, LogOut, MoreVertical, Users, Clock, XCircle } from 'lucide-svelte';
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 { getAvatarUrl } from "$lib/utils/avatarGenerator";
import {
Bell,
X,
ArrowLeft,
LogOut,
MoreVertical,
Users,
Clock,
XCircle,
} from "lucide-svelte";
interface Props {
conversaId: string;
}
interface Props {
conversaId: string;
}
let { conversaId }: Props = $props();
let { conversaId }: Props = $props();
const client = useConvexClient();
const client = useConvexClient();
// Token é passado automaticamente via interceptadores em +layout.svelte
// Token é passado automaticamente via interceptadores em +layout.svelte
let showScheduleModal = $state(false);
let showSalaManager = $state(false);
let showAdminMenu = $state(false);
let showNotificacaoModal = $state(false);
let showScheduleModal = $state(false);
let showSalaManager = $state(false);
let showAdminMenu = $state(false);
let showNotificacaoModal = $state(false);
const conversas = useQuery(api.chat.listarConversas, {});
const isAdmin = useQuery(api.chat.verificarSeEhAdmin, {
conversaId: conversaId as Id<'conversas'>
});
const conversas = useQuery(api.chat.listarConversas, {});
const isAdmin = useQuery(api.chat.verificarSeEhAdmin, {
conversaId: conversaId as Id<"conversas">,
});
const conversa = $derived(() => {
console.log('🔍 [ChatWindow] Buscando conversa ID:', conversaId);
console.log('📋 [ChatWindow] Conversas disponíveis:', conversas?.data);
const conversa = $derived(() => {
console.log("🔍 [ChatWindow] Buscando conversa ID:", conversaId);
console.log("📋 [ChatWindow] Conversas disponíveis:", conversas?.data);
if (!conversas?.data || !Array.isArray(conversas.data)) {
console.log('⚠️ [ChatWindow] conversas.data não é um array ou está vazio');
return null;
}
if (!conversas?.data || !Array.isArray(conversas.data)) {
console.log(
"⚠️ [ChatWindow] conversas.data não é um array ou está vazio",
);
return null;
}
const encontrada = conversas.data.find((c: { _id: string }) => c._id === conversaId);
console.log('✅ [ChatWindow] Conversa encontrada:', encontrada);
return encontrada;
});
const encontrada = conversas.data.find(
(c: { _id: string }) => c._id === conversaId,
);
console.log("✅ [ChatWindow] Conversa encontrada:", encontrada);
return encontrada;
});
function getNomeConversa(): string {
const c = conversa();
if (!c) return 'Carregando...';
if (c.tipo === 'grupo' || c.tipo === 'sala_reuniao') {
return c.nome || (c.tipo === 'sala_reuniao' ? 'Sala sem nome' : 'Grupo sem nome');
}
return c.outroUsuario?.nome || 'Usuário';
}
function getNomeConversa(): string {
const c = conversa();
if (!c) return "Carregando...";
if (c.tipo === "grupo" || c.tipo === "sala_reuniao") {
return (
c.nome ||
(c.tipo === "sala_reuniao" ? "Sala sem nome" : "Grupo sem nome")
);
}
return c.outroUsuario?.nome || "Usuário";
}
function getAvatarConversa(): string {
const c = conversa();
if (!c) return '💬';
if (c.tipo === 'grupo') {
return c.avatar || '👥';
}
if (c.outroUsuario?.avatar) {
return c.outroUsuario.avatar;
}
return '👤';
}
function getAvatarConversa(): string {
const c = conversa();
if (!c) return "💬";
if (c.tipo === "grupo") {
return c.avatar || "👥";
}
if (c.outroUsuario?.avatar) {
return c.outroUsuario.avatar;
}
return "👤";
}
function getStatusConversa(): 'online' | 'offline' | 'ausente' | 'externo' | 'em_reuniao' | null {
const c = conversa();
if (c && c.tipo === 'individual' && c.outroUsuario) {
return (
(c.outroUsuario.statusPresenca as
| 'online'
| 'offline'
| 'ausente'
| 'externo'
| 'em_reuniao') || 'offline'
);
}
return null;
}
function getStatusConversa():
| "online"
| "offline"
| "ausente"
| "externo"
| "em_reuniao"
| null {
const c = conversa();
if (c && c.tipo === "individual" && c.outroUsuario) {
return (
(c.outroUsuario.statusPresenca as
| "online"
| "offline"
| "ausente"
| "externo"
| "em_reuniao") || "offline"
);
}
return null;
}
function getStatusMensagem(): string | null {
const c = conversa();
if (c && c.tipo === 'individual' && c.outroUsuario) {
return c.outroUsuario.statusMensagem || null;
}
return null;
}
function getStatusMensagem(): string | null {
const c = conversa();
if (c && c.tipo === "individual" && c.outroUsuario) {
return c.outroUsuario.statusMensagem || null;
}
return null;
}
async function handleSairGrupoOuSala() {
const c = conversa();
if (!c || (c.tipo !== 'grupo' && c.tipo !== 'sala_reuniao')) return;
async function handleSairGrupoOuSala() {
const c = conversa();
if (!c || (c.tipo !== "grupo" && c.tipo !== "sala_reuniao")) return;
const tipoTexto = c.tipo === 'sala_reuniao' ? 'sala de reunião' : 'grupo';
if (!confirm(`Tem certeza que deseja sair da ${tipoTexto} "${c.nome || 'Sem nome'}"?`)) {
return;
}
const tipoTexto = c.tipo === "sala_reuniao" ? "sala de reunião" : "grupo";
if (
!confirm(
`Tem certeza que deseja sair da ${tipoTexto} "${c.nome || "Sem nome"}"?`,
)
) {
return;
}
try {
const resultado = await client.mutation(api.chat.sairGrupoOuSala, {
conversaId: conversaId as Id<'conversas'>
});
try {
const resultado = await client.mutation(api.chat.sairGrupoOuSala, {
conversaId: conversaId as Id<"conversas">,
});
if (resultado.sucesso) {
voltarParaLista();
} else {
alert(resultado.erro || 'Erro ao sair da conversa');
}
} catch (error) {
console.error('Erro ao sair da conversa:', error);
const errorMessage = error instanceof Error ? error.message : 'Erro ao sair da conversa';
alert(errorMessage);
}
}
function handleDocumentClick() {
if (showAdminMenu) showAdminMenu = false;
}
if (resultado.sucesso) {
voltarParaLista();
} else {
alert(resultado.erro || "Erro ao sair da conversa");
}
} catch (error) {
console.error("Erro ao sair da conversa:", error);
const errorMessage =
error instanceof Error ? error.message : "Erro ao sair da conversa";
alert(errorMessage);
}
}
</script>
<svelte:window onclick={handleDocumentClick} />
<div class="flex flex-col h-full" onclick={() => (showAdminMenu = false)}>
<!-- Header -->
<div
class="flex items-center gap-3 px-4 py-3 border-b border-base-300 bg-base-200"
onclick={(e) => e.stopPropagation()}
>
<!-- Botão Voltar -->
<button
type="button"
class="btn btn-ghost btn-sm btn-circle hover:bg-primary/20 transition-all duration-200 hover:scale-110"
onclick={voltarParaLista}
aria-label="Voltar"
title="Voltar para lista de conversas"
>
<ArrowLeft class="w-6 h-6 text-primary" strokeWidth={2.5} />
</button>
<div class="flex h-full flex-col">
<!-- Header -->
<div class="border-base-300 bg-base-200 flex items-center gap-3 border-b px-4 py-3">
<!-- Botão Voltar -->
<button
type="button"
class="btn btn-circle hover:bg-primary/20 transition-all duration-200 hover:scale-110"
onclick={voltarParaLista}
aria-label="Voltar"
title="Voltar para lista de conversas"
>
<ArrowLeft class="text-primary h-6 w-6" />
</button>
<!-- Avatar e Info -->
<div class="relative shrink-0">
{#if conversa() && conversa()?.tipo === "individual" && conversa()?.outroUsuario}
<UserAvatar
avatar={conversa()?.outroUsuario?.avatar}
fotoPerfilUrl={conversa()?.outroUsuario?.fotoPerfilUrl}
nome={conversa()?.outroUsuario?.nome || "Usuário"}
size="md"
/>
{:else}
<div
class="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center text-xl"
>
{getAvatarConversa()}
</div>
{/if}
{#if getStatusConversa()}
<div class="absolute bottom-0 right-0">
<UserStatusBadge status={getStatusConversa()} size="sm" />
</div>
{/if}
</div>
<!-- Avatar e Info -->
<div class="relative shrink-0">
{#if conversa() && conversa()?.tipo === 'individual' && conversa()?.outroUsuario}
<UserAvatar
avatar={conversa()?.outroUsuario?.avatar}
fotoPerfilUrl={conversa()?.outroUsuario?.fotoPerfilUrl}
nome={conversa()?.outroUsuario?.nome || 'Usuário'}
size="md"
/>
{:else}
<div class="bg-primary/20 flex h-10 w-10 items-center justify-center rounded-full text-xl">
{getAvatarConversa()}
</div>
{/if}
{#if getStatusConversa()}
<div class="absolute right-0 bottom-0">
<UserStatusBadge status={getStatusConversa()} size="sm" />
</div>
{/if}
</div>
<div class="flex-1 min-w-0">
<p class="font-semibold text-base-content truncate">
{getNomeConversa()}
</p>
{#if getStatusMensagem()}
<p class="text-xs text-base-content/60 truncate">
{getStatusMensagem()}
</p>
{:else if getStatusConversa()}
<p class="text-xs text-base-content/60">
{getStatusConversa() === "online"
? "Online"
: getStatusConversa() === "ausente"
? "Ausente"
: getStatusConversa() === "em_reuniao"
? "Em reunião"
: getStatusConversa() === "externo"
? "Externo"
: "Offline"}
</p>
{:else if conversa() && (conversa()?.tipo === "grupo" || conversa()?.tipo === "sala_reuniao")}
<div class="flex items-center gap-2 mt-1">
<p class="text-xs text-base-content/60">
{conversa()?.participantesInfo?.length || 0}
{conversa()?.participantesInfo?.length === 1
? "participante"
: "participantes"}
</p>
{#if conversa()?.participantesInfo && conversa()?.participantesInfo.length > 0}
<div class="flex items-center gap-2">
<div class="flex -space-x-2">
{#each conversa()?.participantesInfo.slice(0, 5) as participante (participante._id)}
<div
class="relative w-5 h-5 rounded-full border-2 border-base-200 overflow-hidden bg-base-200"
title={participante.nome}
>
{#if participante.fotoPerfilUrl}
<img
src={participante.fotoPerfilUrl}
alt={participante.nome}
class="w-full h-full object-cover"
/>
{:else if participante.avatar}
<img
src={getAvatarUrl(participante.avatar)}
alt={participante.nome}
class="w-full h-full object-cover"
/>
{:else}
<img
src={getAvatarUrl(participante.nome)}
alt={participante.nome}
class="w-full h-full object-cover"
/>
{/if}
</div>
{/each}
{#if conversa()?.participantesInfo.length > 5}
<div
class="w-5 h-5 rounded-full border-2 border-base-200 bg-base-300 flex items-center justify-center text-[8px] font-semibold text-base-content/70"
title={`+${conversa()?.participantesInfo.length - 5} mais`}
>
+{conversa()?.participantesInfo.length - 5}
</div>
{/if}
</div>
{#if conversa()?.tipo === "sala_reuniao" && isAdmin?.data}
<span
class="text-[10px] text-primary font-semibold ml-1 whitespace-nowrap"
title="Você é administrador desta sala">• Admin</span
>
{/if}
</div>
{/if}
</div>
{/if}
</div>
<div class="min-w-0 flex-1">
<p class="text-base-content truncate font-semibold">
{getNomeConversa()}
</p>
{#if getStatusMensagem()}
<p class="text-base-content/60 truncate text-xs">
{getStatusMensagem()}
</p>
{:else if getStatusConversa()}
<p class="text-base-content/60 text-xs">
{getStatusConversa() === 'online'
? 'Online'
: getStatusConversa() === 'ausente'
? 'Ausente'
: getStatusConversa() === 'em_reuniao'
? 'Em reunião'
: getStatusConversa() === 'externo'
? 'Externo'
: 'Offline'}
</p>
{:else if conversa() && (conversa()?.tipo === 'grupo' || conversa()?.tipo === 'sala_reuniao')}
<div class="mt-1 flex items-center gap-2">
<p class="text-base-content/60 text-xs">
{conversa()?.participantesInfo?.length || 0}
{conversa()?.participantesInfo?.length === 1 ? 'participante' : 'participantes'}
</p>
{#if conversa()?.participantesInfo && conversa()?.participantesInfo.length > 0}
<div class="flex items-center gap-2">
<div class="flex -space-x-2">
{#each conversa()?.participantesInfo.slice(0, 5) as participante (participante._id)}
<div
class="border-base-200 bg-base-200 relative h-5 w-5 overflow-hidden rounded-full border-2"
title={participante.nome}
>
{#if participante.fotoPerfilUrl}
<img
src={participante.fotoPerfilUrl}
alt={participante.nome}
class="h-full w-full object-cover"
/>
{:else if participante.avatar}
<img
src={getAvatarUrl(participante.avatar)}
alt={participante.nome}
class="h-full w-full object-cover"
/>
{:else}
<img
src={getAvatarUrl(participante.nome)}
alt={participante.nome}
class="h-full w-full object-cover"
/>
{/if}
</div>
{/each}
{#if conversa()?.participantesInfo.length > 5}
<div
class="border-base-200 bg-base-300 text-base-content/70 flex h-5 w-5 items-center justify-center rounded-full border-2 text-[8px] font-semibold"
title={`+${conversa()?.participantesInfo.length - 5} mais`}
>
+{conversa()?.participantesInfo.length - 5}
</div>
{/if}
</div>
{#if conversa()?.tipo === 'sala_reuniao' && isAdmin?.data}
<span
class="text-primary ml-1 text-[10px] font-semibold whitespace-nowrap"
title="Você é administrador desta sala">• Admin</span
>
{/if}
</div>
{/if}
</div>
{/if}
</div>
<!-- Botões de ação -->
<div class="flex items-center gap-1">
<!-- Botão Sair (apenas para grupos e salas de reunião) -->
{#if conversa() && (conversa()?.tipo === "grupo" || conversa()?.tipo === "sala_reuniao")}
<button
type="button"
class="flex items-center justify-center w-9 h-9 rounded-lg transition-all duration-300 group relative overflow-hidden"
style="background: rgba(239, 68, 68, 0.1); border: 1px solid rgba(239, 68, 68, 0.2);"
onclick={(e) => {
e.stopPropagation();
handleSairGrupoOuSala();
}}
aria-label="Sair"
title="Sair da conversa"
>
<div
class="absolute inset-0 bg-red-500/0 group-hover:bg-red-500/10 transition-colors duration-300"
></div>
<LogOut
class="w-5 h-5 text-red-500 relative z-10 group-hover:scale-110 transition-transform"
strokeWidth={2}
/>
</button>
{/if}
<!-- Botões de ação -->
<div class="flex items-center gap-1">
<!-- Botão Sair (apenas para grupos e salas de reunião) -->
{#if conversa() && (conversa()?.tipo === 'grupo' || conversa()?.tipo === 'sala_reuniao')}
<button
type="button"
class="group relative flex h-9 w-9 items-center justify-center overflow-hidden rounded-lg transition-all duration-300"
style="background: rgba(239, 68, 68, 0.1); border: 1px solid rgba(239, 68, 68, 0.2);"
onclick={(e) => {
e.stopPropagation();
handleSairGrupoOuSala();
}}
aria-label="Sair"
title="Sair da conversa"
>
<div
class="absolute inset-0 bg-red-500/0 transition-colors duration-300 group-hover:bg-red-500/10"
></div>
<LogOut
class="relative z-10 h-5 w-5 text-red-500 transition-transform group-hover:scale-110"
strokeWidth={2}
/>
</button>
{/if}
<!-- Botão Menu Administrativo (apenas para salas de reunião e apenas para admins) -->
{#if conversa()?.tipo === "sala_reuniao" && isAdmin?.data}
<div class="relative admin-menu-container">
<button
type="button"
class="flex items-center justify-center w-9 h-9 rounded-lg transition-all duration-300 group relative overflow-hidden"
style="background: rgba(59, 130, 246, 0.1); border: 1px solid rgba(59, 130, 246, 0.2);"
onclick={(e) => {
e.stopPropagation();
showAdminMenu = !showAdminMenu;
}}
aria-label="Menu administrativo"
title="Recursos administrativos"
>
<div
class="absolute inset-0 bg-blue-500/0 group-hover:bg-blue-500/10 transition-colors duration-300"
></div>
<MoreVertical
class="w-5 h-5 text-blue-500 relative z-10 group-hover:scale-110 transition-transform"
strokeWidth={2}
/>
</button>
{#if showAdminMenu}
<ul
class="absolute right-0 top-full mt-2 bg-base-100 rounded-lg shadow-xl border border-base-300 w-56 z-[100] overflow-hidden"
onclick={(e) => e.stopPropagation()}
>
<li>
<button
type="button"
class="w-full text-left px-4 py-3 hover:bg-base-200 transition-colors flex items-center gap-2"
onclick={(e) => {
e.stopPropagation();
showSalaManager = true;
showAdminMenu = false;
}}
>
<Users class="w-4 h-4" strokeWidth={2} />
Gerenciar Participantes
</button>
</li>
<li>
<button
type="button"
class="w-full text-left px-4 py-3 hover:bg-base-200 transition-colors flex items-center gap-2"
onclick={(e) => {
e.stopPropagation();
showNotificacaoModal = true;
showAdminMenu = false;
}}
>
<Bell class="w-4 h-4" strokeWidth={2} />
Enviar Notificação
</button>
</li>
<li>
<button
type="button"
class="w-full text-left px-4 py-3 hover:bg-error/10 transition-colors flex items-center gap-2 text-error"
onclick={(e) => {
e.stopPropagation();
(async () => {
if (
!confirm(
"Tem certeza que deseja encerrar esta reunião? Todos os participantes serão removidos.",
)
)
return;
try {
const resultado = await client.mutation(
api.chat.encerrarReuniao,
{
conversaId: conversaId as Id<"conversas">,
},
);
if (resultado.sucesso) {
alert("Reunião encerrada com sucesso!");
voltarParaLista();
} else {
alert(resultado.erro || "Erro ao encerrar reunião");
}
} catch (error) {
const errorMessage =
error instanceof Error
? error.message
: "Erro ao encerrar reunião";
alert(errorMessage);
}
showAdminMenu = false;
})();
}}
>
<XCircle class="w-4 h-4" strokeWidth={2} />
Encerrar Reunião
</button>
</li>
</ul>
{/if}
</div>
{/if}
<!-- Botão Menu Administrativo (apenas para salas de reunião e apenas para admins) -->
{#if conversa()?.tipo === 'sala_reuniao' && isAdmin?.data}
<div class="admin-menu-container relative">
<button
type="button"
class="group relative flex h-9 w-9 items-center justify-center overflow-hidden rounded-lg transition-all duration-300"
style="background: rgba(59, 130, 246, 0.1); border: 1px solid rgba(59, 130, 246, 0.2);"
onclick={(e) => {
e.stopPropagation();
showAdminMenu = !showAdminMenu;
}}
aria-label="Menu administrativo"
title="Recursos administrativos"
>
<div
class="absolute inset-0 bg-blue-500/0 transition-colors duration-300 group-hover:bg-blue-500/10"
></div>
<MoreVertical
class="relative z-10 h-5 w-5 text-blue-500 transition-transform group-hover:scale-110"
strokeWidth={2}
/>
</button>
{#if showAdminMenu}
<ul
class="bg-base-100 border-base-300 absolute top-full right-0 z-[100] mt-2 w-56 overflow-hidden rounded-lg border shadow-xl"
>
<li>
<button
type="button"
class="hover:bg-base-200 flex w-full items-center gap-2 px-4 py-3 text-left transition-colors"
onclick={(e) => {
e.stopPropagation();
showSalaManager = true;
showAdminMenu = false;
}}
>
<Users class="h-4 w-4" strokeWidth={2} />
Gerenciar Participantes
</button>
</li>
<li>
<button
type="button"
class="hover:bg-base-200 flex w-full items-center gap-2 px-4 py-3 text-left transition-colors"
onclick={(e) => {
e.stopPropagation();
showNotificacaoModal = true;
showAdminMenu = false;
}}
>
<Bell class="h-4 w-4" strokeWidth={2} />
Enviar Notificação
</button>
</li>
<li>
<button
type="button"
class="hover:bg-error/10 text-error flex w-full items-center gap-2 px-4 py-3 text-left transition-colors"
onclick={(e) => {
e.stopPropagation();
(async () => {
if (
!confirm(
'Tem certeza que deseja encerrar esta reunião? Todos os participantes serão removidos.'
)
)
return;
try {
const resultado = await client.mutation(api.chat.encerrarReuniao, {
conversaId: conversaId as Id<'conversas'>
});
if (resultado.sucesso) {
alert('Reunião encerrada com sucesso!');
voltarParaLista();
} else {
alert(resultado.erro || 'Erro ao encerrar reunião');
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Erro ao encerrar reunião';
alert(errorMessage);
}
showAdminMenu = false;
})();
}}
>
<XCircle class="h-4 w-4" strokeWidth={2} />
Encerrar Reunião
</button>
</li>
</ul>
{/if}
</div>
{/if}
<!-- Botão Agendar MODERNO -->
<button
type="button"
class="flex items-center justify-center w-9 h-9 rounded-lg transition-all duration-300 group relative overflow-hidden"
style="background: rgba(139, 92, 246, 0.1); border: 1px solid rgba(139, 92, 246, 0.2);"
onclick={() => (showScheduleModal = true)}
aria-label="Agendar mensagem"
title="Agendar mensagem"
>
<div
class="absolute inset-0 bg-purple-500/0 group-hover:bg-purple-500/10 transition-colors duration-300"
></div>
<Clock
class="w-5 h-5 text-purple-500 relative z-10 group-hover:scale-110 transition-transform"
strokeWidth={2}
/>
</button>
</div>
</div>
<!-- Botão Agendar MODERNO -->
<button
type="button"
class="group relative flex h-9 w-9 items-center justify-center overflow-hidden rounded-lg transition-all duration-300"
style="background: rgba(139, 92, 246, 0.1); border: 1px solid rgba(139, 92, 246, 0.2);"
onclick={() => (showScheduleModal = true)}
aria-label="Agendar mensagem"
title="Agendar mensagem"
>
<div
class="absolute inset-0 bg-purple-500/0 transition-colors duration-300 group-hover:bg-purple-500/10"
></div>
<Clock
class="relative z-10 h-5 w-5 text-purple-500 transition-transform group-hover:scale-110"
strokeWidth={2}
/>
</button>
</div>
</div>
<!-- Mensagens -->
<div class="flex-1 overflow-hidden min-h-0">
<MessageList conversaId={conversaId as Id<"conversas">} />
</div>
<!-- Mensagens -->
<div class="min-h-0 flex-1 overflow-hidden">
<MessageList conversaId={conversaId as Id<'conversas'>} />
</div>
<!-- Input -->
<div class="border-base-300 shrink-0 border-t">
<MessageInput conversaId={conversaId as Id<'conversas'>} />
</div>
<!-- Input -->
<div class="border-t border-base-300 shrink-0">
<MessageInput conversaId={conversaId as Id<"conversas">} />
</div>
</div>
<!-- Modal de Agendamento -->
{#if showScheduleModal}
<ScheduleMessageModal
conversaId={conversaId as Id<'conversas'>}
onClose={() => (showScheduleModal = false)}
/>
<ScheduleMessageModal
conversaId={conversaId as Id<"conversas">}
onClose={() => (showScheduleModal = false)}
/>
{/if}
<!-- Modal de Gerenciamento de Sala -->
{#if showSalaManager && conversa()?.tipo === 'sala_reuniao'}
<SalaReuniaoManager
conversaId={conversaId as Id<'conversas'>}
isAdmin={isAdmin?.data ?? false}
onClose={() => (showSalaManager = false)}
/>
{#if showSalaManager && conversa()?.tipo === "sala_reuniao"}
<SalaReuniaoManager
conversaId={conversaId as Id<"conversas">}
isAdmin={isAdmin?.data ?? false}
onClose={() => (showSalaManager = false)}
/>
{/if}
<!-- Modal de Enviar Notificação -->
{#if showNotificacaoModal && conversa()?.tipo === 'sala_reuniao' && isAdmin?.data}
<dialog
class="modal modal-open"
onclick={(e) => e.target === e.currentTarget && (showNotificacaoModal = false)}
>
<div class="modal-box max-w-md" onclick={(e) => e.stopPropagation()}>
<div class="border-base-300 flex items-center justify-between border-b px-6 py-4">
<h2 class="flex items-center gap-2 text-xl font-semibold">
<Bell class="text-primary h-5 w-5" />
Enviar Notificação
</h2>
<button
type="button"
class="btn btn-sm btn-circle"
onclick={() => (showNotificacaoModal = false)}
>
<X class="h-5 w-5" />
</button>
</div>
<div class="p-6">
<form
onsubmit={async (e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const titulo = formData.get('titulo') as string;
const mensagem = formData.get('mensagem') as string;
{#if showNotificacaoModal && conversa()?.tipo === "sala_reuniao" && isAdmin?.data}
<dialog
class="modal modal-open"
onclick={(e) =>
e.target === e.currentTarget && (showNotificacaoModal = false)}
>
<div class="modal-box max-w-md" onclick={(e) => e.stopPropagation()}>
<div
class="flex items-center justify-between px-6 py-4 border-b border-base-300"
>
<h2 class="text-xl font-semibold flex items-center gap-2">
<Bell class="w-5 h-5 text-primary" />
Enviar Notificação
</h2>
<button
type="button"
class="btn btn-ghost btn-sm btn-circle"
onclick={() => (showNotificacaoModal = false)}
>
<X class="w-5 h-5" />
</button>
</div>
<div class="p-6">
<form
onsubmit={async (e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const titulo = formData.get("titulo") as string;
const mensagem = formData.get("mensagem") as string;
if (!titulo.trim() || !mensagem.trim()) {
alert('Preencha todos os campos');
return;
}
if (!titulo.trim() || !mensagem.trim()) {
alert("Preencha todos os campos");
return;
}
try {
const resultado = await client.mutation(api.chat.enviarNotificacaoReuniao, {
conversaId: conversaId as Id<'conversas'>,
titulo: titulo.trim(),
mensagem: mensagem.trim()
});
try {
const resultado = await client.mutation(
api.chat.enviarNotificacaoReuniao,
{
conversaId: conversaId as Id<"conversas">,
titulo: titulo.trim(),
mensagem: mensagem.trim(),
},
);
if (resultado.sucesso) {
alert('Notificação enviada com sucesso!');
showNotificacaoModal = false;
} else {
alert(resultado.erro || 'Erro ao enviar notificação');
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Erro ao enviar notificação';
alert(errorMessage);
}
}}
>
<div class="mb-4">
<label class="label">
<span class="label-text">Título</span>
</label>
<input
type="text"
name="titulo"
placeholder="Título da notificação"
class="input input-bordered w-full"
required
/>
</div>
<div class="mb-4">
<label class="label">
<span class="label-text">Mensagem</span>
</label>
<textarea
name="mensagem"
placeholder="Mensagem da notificação"
class="textarea textarea-bordered w-full"
rows="4"
required
></textarea>
</div>
<div class="flex gap-2">
<button type="button" class="btn flex-1" onclick={() => (showNotificacaoModal = false)}>
Cancelar
</button>
<button type="submit" class="btn btn-primary flex-1"> Enviar </button>
</div>
</form>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button type="button" onclick={() => (showNotificacaoModal = false)}>fechar</button>
</form>
</dialog>
if (resultado.sucesso) {
alert("Notificação enviada com sucesso!");
showNotificacaoModal = false;
} else {
alert(resultado.erro || "Erro ao enviar notificação");
}
} catch (error) {
const errorMessage =
error instanceof Error
? error.message
: "Erro ao enviar notificação";
alert(errorMessage);
}
}}
>
<div class="mb-4">
<label class="label">
<span class="label-text">Título</span>
</label>
<input
type="text"
name="titulo"
placeholder="Título da notificação"
class="input input-bordered w-full"
required
/>
</div>
<div class="mb-4">
<label class="label">
<span class="label-text">Mensagem</span>
</label>
<textarea
name="mensagem"
placeholder="Mensagem da notificação"
class="textarea textarea-bordered w-full"
rows="4"
required
></textarea>
</div>
<div class="flex gap-2">
<button
type="button"
class="btn btn-ghost flex-1"
onclick={() => (showNotificacaoModal = false)}
>
Cancelar
</button>
<button type="submit" class="btn btn-primary flex-1">
Enviar
</button>
</div>
</form>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button type="button" onclick={() => (showNotificacaoModal = false)}
>fechar</button
>
</form>
</dialog>
{/if}

View File

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

View File

@@ -1,435 +1,487 @@
<script lang="ts">
import { useQuery, useConvexClient } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import UserAvatar from './UserAvatar.svelte';
import UserStatusBadge from './UserStatusBadge.svelte';
import { X, Users, UserPlus, ArrowUp, ArrowDown, Trash2, Search } from 'lucide-svelte';
import { useQuery, useConvexClient } from "convex-svelte";
import { api } from "@sgse-app/backend/convex/_generated/api";
import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
import UserAvatar from "./UserAvatar.svelte";
import UserStatusBadge from "./UserStatusBadge.svelte";
import {
X,
Users,
UserPlus,
ArrowUp,
ArrowDown,
Trash2,
Search,
} from "lucide-svelte";
interface Props {
conversaId: Id<'conversas'>;
isAdmin: boolean;
onClose: () => void;
}
interface Props {
conversaId: Id<"conversas">;
isAdmin: boolean;
onClose: () => void;
}
let { conversaId, isAdmin, onClose }: Props = $props();
let { conversaId, isAdmin, onClose }: Props = $props();
const client = useConvexClient();
const conversas = useQuery(api.chat.listarConversas, {});
const todosUsuariosQuery = useQuery(api.chat.listarTodosUsuarios, {});
const client = useConvexClient();
const conversas = useQuery(api.chat.listarConversas, {});
const todosUsuariosQuery = useQuery(api.chat.listarTodosUsuarios, {});
let activeTab = $state<'participantes' | 'adicionar'>('participantes');
let searchQuery = $state('');
let loading = $state<string | null>(null);
let error = $state<string | null>(null);
let activeTab = $state<"participantes" | "adicionar">("participantes");
let searchQuery = $state("");
let loading = $state<string | null>(null);
let error = $state<string | null>(null);
const conversa = $derived(() => {
if (!conversas?.data) return null;
return conversas.data.find((c: any) => c._id === conversaId);
});
const conversa = $derived(() => {
if (!conversas?.data) return null;
return conversas.data.find((c: any) => c._id === conversaId);
});
const todosUsuarios = $derived(() => {
return todosUsuariosQuery?.data || [];
});
const todosUsuarios = $derived(() => {
return todosUsuariosQuery?.data || [];
});
const participantes = $derived(() => {
try {
const conv = conversa();
const usuarios = todosUsuarios();
if (!conv || !usuarios || usuarios.length === 0) return [];
const participantes = $derived(() => {
try {
const conv = conversa();
const usuarios = todosUsuarios();
if (!conv || !usuarios || usuarios.length === 0) return [];
const participantesInfo = conv.participantesInfo || [];
if (!Array.isArray(participantesInfo) || participantesInfo.length === 0) return [];
const participantesInfo = conv.participantesInfo || [];
if (!Array.isArray(participantesInfo) || participantesInfo.length === 0)
return [];
return participantesInfo
.map((p: any) => {
try {
// p pode ser um objeto com _id ou apenas um ID
const participanteId = p?._id || p;
if (!participanteId) return null;
return participantesInfo
.map((p: any) => {
try {
// p pode ser um objeto com _id ou apenas um ID
const participanteId = p?._id || p;
if (!participanteId) return null;
const usuario = usuarios.find((u: any) => {
try {
return String(u?._id) === String(participanteId);
} catch {
return false;
}
});
if (!usuario) return null;
const usuario = usuarios.find((u: any) => {
try {
return String(u?._id) === String(participanteId);
} catch {
return false;
}
});
if (!usuario) return null;
// Combinar dados do usuário com dados do participante (se p for objeto)
return {
...usuario,
...(typeof p === 'object' && p !== null && p !== undefined ? p : {}),
// Garantir que _id existe e priorizar o do usuario
_id: usuario._id
};
} catch (err) {
console.error('Erro ao processar participante:', err, p);
return null;
}
})
.filter((p: any) => p !== null && p._id);
} catch (err) {
console.error('Erro ao calcular participantes:', err);
return [];
}
});
// Combinar dados do usuário com dados do participante (se p for objeto)
return {
...usuario,
...(typeof p === "object" && p !== null && p !== undefined
? p
: {}),
// Garantir que _id existe e priorizar o do usuario
_id: usuario._id,
};
} catch (err) {
console.error("Erro ao processar participante:", err, p);
return null;
}
})
.filter((p: any) => p !== null && p._id);
} catch (err) {
console.error("Erro ao calcular participantes:", err);
return [];
}
});
const administradoresIds = $derived(() => {
return conversa()?.administradores || [];
});
const administradoresIds = $derived(() => {
return conversa()?.administradores || [];
});
const usuariosDisponiveis = $derived(() => {
const usuarios = todosUsuarios();
if (!usuarios || usuarios.length === 0) return [];
const participantesIds = conversa()?.participantes || [];
return usuarios.filter(
(u: any) => !participantesIds.some((pid: any) => String(pid) === String(u._id))
);
});
const usuariosDisponiveis = $derived(() => {
const usuarios = todosUsuarios();
if (!usuarios || usuarios.length === 0) return [];
const participantesIds = conversa()?.participantes || [];
return usuarios.filter(
(u: any) =>
!participantesIds.some((pid: any) => String(pid) === String(u._id)),
);
});
const usuariosFiltrados = $derived(() => {
const disponiveis = usuariosDisponiveis();
if (!searchQuery.trim()) return disponiveis;
const query = searchQuery.toLowerCase();
return disponiveis.filter(
(u: any) =>
(u.nome || '').toLowerCase().includes(query) ||
(u.email || '').toLowerCase().includes(query) ||
(u.matricula || '').toLowerCase().includes(query)
);
});
const usuariosFiltrados = $derived(() => {
const disponiveis = usuariosDisponiveis();
if (!searchQuery.trim()) return disponiveis;
const query = searchQuery.toLowerCase();
return disponiveis.filter(
(u: any) =>
(u.nome || "").toLowerCase().includes(query) ||
(u.email || "").toLowerCase().includes(query) ||
(u.matricula || "").toLowerCase().includes(query),
);
});
function isParticipanteAdmin(usuarioId: string): boolean {
const admins = administradoresIds();
return admins.some((adminId: any) => String(adminId) === String(usuarioId));
}
function isParticipanteAdmin(usuarioId: string): boolean {
const admins = administradoresIds();
return admins.some((adminId: any) => String(adminId) === String(usuarioId));
}
function isCriador(usuarioId: string): boolean {
const criadoPor = conversa()?.criadoPor;
return criadoPor ? String(criadoPor) === String(usuarioId) : false;
}
function isCriador(usuarioId: string): boolean {
const criadoPor = conversa()?.criadoPor;
return criadoPor ? String(criadoPor) === String(usuarioId) : false;
}
async function removerParticipante(participanteId: string) {
if (!confirm('Tem certeza que deseja remover este participante?')) return;
async function removerParticipante(participanteId: string) {
if (!confirm("Tem certeza que deseja remover este participante?")) return;
try {
loading = `remover-${participanteId}`;
error = null;
const resultado = await client.mutation(api.chat.removerParticipanteSala, {
conversaId,
participanteId: participanteId as any
});
try {
loading = `remover-${participanteId}`;
error = null;
const resultado = await client.mutation(
api.chat.removerParticipanteSala,
{
conversaId,
participanteId: participanteId as any,
},
);
if (!resultado.sucesso) {
error = resultado.erro || 'Erro ao remover participante';
}
} catch (err: any) {
error = err.message || 'Erro ao remover participante';
} finally {
loading = null;
}
}
if (!resultado.sucesso) {
error = resultado.erro || "Erro ao remover participante";
}
} catch (err: any) {
error = err.message || "Erro ao remover participante";
} finally {
loading = null;
}
}
async function promoverAdmin(participanteId: string) {
if (!confirm('Promover este participante a administrador?')) return;
async function promoverAdmin(participanteId: string) {
if (!confirm("Promover este participante a administrador?")) return;
try {
loading = `promover-${participanteId}`;
error = null;
const resultado = await client.mutation(api.chat.promoverAdministrador, {
conversaId,
participanteId: participanteId as any
});
try {
loading = `promover-${participanteId}`;
error = null;
const resultado = await client.mutation(api.chat.promoverAdministrador, {
conversaId,
participanteId: participanteId as any,
});
if (!resultado.sucesso) {
error = resultado.erro || 'Erro ao promover administrador';
}
} catch (err: any) {
error = err.message || 'Erro ao promover administrador';
} finally {
loading = null;
}
}
if (!resultado.sucesso) {
error = resultado.erro || "Erro ao promover administrador";
}
} catch (err: any) {
error = err.message || "Erro ao promover administrador";
} finally {
loading = null;
}
}
async function rebaixarAdmin(participanteId: string) {
if (!confirm('Rebaixar este administrador a participante?')) return;
async function rebaixarAdmin(participanteId: string) {
if (!confirm("Rebaixar este administrador a participante?")) return;
try {
loading = `rebaixar-${participanteId}`;
error = null;
const resultado = await client.mutation(api.chat.rebaixarAdministrador, {
conversaId,
participanteId: participanteId as any
});
try {
loading = `rebaixar-${participanteId}`;
error = null;
const resultado = await client.mutation(api.chat.rebaixarAdministrador, {
conversaId,
participanteId: participanteId as any,
});
if (!resultado.sucesso) {
error = resultado.erro || 'Erro ao rebaixar administrador';
}
} catch (err: any) {
error = err.message || 'Erro ao rebaixar administrador';
} finally {
loading = null;
}
}
if (!resultado.sucesso) {
error = resultado.erro || "Erro ao rebaixar administrador";
}
} catch (err: any) {
error = err.message || "Erro ao rebaixar administrador";
} finally {
loading = null;
}
}
async function adicionarParticipante(usuarioId: string) {
try {
loading = `adicionar-${usuarioId}`;
error = null;
const resultado = await client.mutation(api.chat.adicionarParticipanteSala, {
conversaId,
participanteId: usuarioId as any
});
async function adicionarParticipante(usuarioId: string) {
try {
loading = `adicionar-${usuarioId}`;
error = null;
const resultado = await client.mutation(
api.chat.adicionarParticipanteSala,
{
conversaId,
participanteId: usuarioId as any,
},
);
if (!resultado.sucesso) {
error = resultado.erro || 'Erro ao adicionar participante';
} else {
searchQuery = '';
}
} catch (err: any) {
error = err.message || 'Erro ao adicionar participante';
} finally {
loading = null;
}
}
if (!resultado.sucesso) {
error = resultado.erro || "Erro ao adicionar participante";
} else {
searchQuery = "";
}
} catch (err: any) {
error = err.message || "Erro ao adicionar participante";
} finally {
loading = null;
}
}
</script>
<dialog class="modal modal-open" onclick={(e) => e.target === e.currentTarget && onClose()}>
<div
class="modal-box flex max-h-[80vh] max-w-2xl flex-col p-0"
onclick={(e) => e.stopPropagation()}
>
<!-- 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-xl font-semibold">
<Users class="text-primary h-5 w-5" />
Gerenciar Sala de Reunião
</h2>
<p class="text-base-content/60 text-sm">
{conversa()?.nome || 'Sem nome'}
</p>
</div>
<button type="button" class="btn btn-sm btn-circle" onclick={onClose} aria-label="Fechar">
<X class="h-5 w-5" />
</button>
</div>
<dialog
class="modal modal-open"
onclick={(e) => e.target === e.currentTarget && onClose()}
>
<div
class="modal-box max-w-2xl max-h-[80vh] flex flex-col p-0"
onclick={(e) => e.stopPropagation()}
>
<!-- Header -->
<div
class="flex items-center justify-between px-6 py-4 border-b border-base-300"
>
<div>
<h2 class="text-xl font-semibold flex items-center gap-2">
<Users class="w-5 h-5 text-primary" />
Gerenciar Sala de Reunião
</h2>
<p class="text-sm text-base-content/60">
{conversa()?.nome || "Sem nome"}
</p>
</div>
<button
type="button"
class="btn btn-ghost btn-sm btn-circle"
onclick={onClose}
aria-label="Fechar"
>
<X class="w-5 h-5" />
</button>
</div>
<!-- Tabs -->
{#if isAdmin}
<div class="tabs tabs-boxed p-4">
<button
type="button"
class={`tab flex items-center gap-2 ${activeTab === 'participantes' ? 'tab-active' : ''}`}
onclick={() => (activeTab = 'participantes')}
>
<Users class="h-4 w-4" />
Participantes
</button>
<button
type="button"
class={`tab flex items-center gap-2 ${activeTab === 'adicionar' ? 'tab-active' : ''}`}
onclick={() => (activeTab = 'adicionar')}
>
<UserPlus class="h-4 w-4" />
Adicionar Participante
</button>
</div>
{/if}
<!-- Tabs -->
{#if isAdmin}
<div class="tabs tabs-boxed p-4">
<button
type="button"
class={`tab flex items-center gap-2 ${activeTab === "participantes" ? "tab-active" : ""}`}
onclick={() => (activeTab = "participantes")}
>
<Users class="w-4 h-4" />
Participantes
</button>
<button
type="button"
class={`tab flex items-center gap-2 ${activeTab === "adicionar" ? "tab-active" : ""}`}
onclick={() => (activeTab = "adicionar")}
>
<UserPlus class="w-4 h-4" />
Adicionar Participante
</button>
</div>
{/if}
<!-- Error Message -->
{#if error}
<div class="alert alert-error mx-6 mt-2">
<span>{error}</span>
<button type="button" class="btn btn-sm btn-ghost" onclick={() => (error = null)}>
<X class="h-4 w-4" />
</button>
</div>
{/if}
<!-- Error Message -->
{#if error}
<div class="mx-6 mt-2 alert alert-error">
<span>{error}</span>
<button
type="button"
class="btn btn-sm btn-ghost"
onclick={() => (error = null)}
>
<X class="w-4 h-4" />
</button>
</div>
{/if}
<!-- Content -->
<div class="flex-1 overflow-y-auto px-6">
{#if !conversas?.data}
<!-- Loading conversas -->
<div class="flex items-center justify-center py-8">
<span class="loading loading-spinner loading-lg"></span>
<span class="text-base-content/60 ml-2 text-sm">Carregando conversa...</span>
</div>
{:else if !todosUsuariosQuery?.data}
<!-- Loading usuários -->
<div class="flex items-center justify-center py-8">
<span class="loading loading-spinner loading-lg"></span>
<span class="text-base-content/60 ml-2 text-sm">Carregando usuários...</span>
</div>
{:else if activeTab === 'participantes'}
<!-- Lista de Participantes -->
<div class="space-y-2 py-2">
{#if participantes().length > 0}
{#each participantes() as participante (String(participante._id))}
{@const participanteId = String(participante._id)}
{@const ehAdmin = isParticipanteAdmin(participanteId)}
{@const ehCriador = isCriador(participanteId)}
{@const isLoading = loading?.includes(participanteId)}
<div
class="border-base-300 hover:bg-base-200 flex items-center gap-3 rounded-lg border p-3 transition-colors"
>
<!-- Avatar -->
<div class="relative shrink-0">
<UserAvatar
avatar={participante.avatar}
fotoPerfilUrl={participante.fotoPerfilUrl || participante.fotoPerfil}
nome={participante.nome || 'Usuário'}
size="sm"
/>
<div class="absolute right-0 bottom-0">
<UserStatusBadge status={participante.statusPresenca || 'offline'} size="sm" />
</div>
</div>
<!-- Content -->
<div class="flex-1 overflow-y-auto px-6">
{#if !conversas?.data}
<!-- Loading conversas -->
<div class="flex items-center justify-center py-8">
<span class="loading loading-spinner loading-lg"></span>
<span class="ml-2 text-sm text-base-content/60"
>Carregando conversa...</span
>
</div>
{:else if !todosUsuariosQuery?.data}
<!-- Loading usuários -->
<div class="flex items-center justify-center py-8">
<span class="loading loading-spinner loading-lg"></span>
<span class="ml-2 text-sm text-base-content/60"
>Carregando usuários...</span
>
</div>
{:else if activeTab === "participantes"}
<!-- Lista de Participantes -->
<div class="space-y-2 py-2">
{#if participantes().length > 0}
{#each participantes() as participante (String(participante._id))}
{@const participanteId = String(participante._id)}
{@const ehAdmin = isParticipanteAdmin(participanteId)}
{@const ehCriador = isCriador(participanteId)}
{@const isLoading = loading?.includes(participanteId)}
<div
class="flex items-center gap-3 p-3 rounded-lg border border-base-300 hover:bg-base-200 transition-colors"
>
<!-- Avatar -->
<div class="relative shrink-0">
<UserAvatar
avatar={participante.avatar}
fotoPerfilUrl={participante.fotoPerfilUrl ||
participante.fotoPerfil}
nome={participante.nome || "Usuário"}
size="sm"
/>
<div class="absolute bottom-0 right-0">
<UserStatusBadge
status={participante.statusPresenca || "offline"}
size="sm"
/>
</div>
</div>
<!-- Info -->
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<p class="text-base-content truncate font-medium">
{participante.nome || 'Usuário'}
</p>
{#if ehAdmin}
<span class="badge badge-primary badge-sm">Admin</span>
{/if}
{#if ehCriador}
<span class="badge badge-secondary badge-sm">Criador</span>
{/if}
</div>
<p class="text-base-content/60 truncate text-sm">
{participante.setor || participante.email || ''}
</p>
</div>
<!-- Info -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<p class="font-medium text-base-content truncate">
{participante.nome || "Usuário"}
</p>
{#if ehAdmin}
<span class="badge badge-primary badge-sm">Admin</span>
{/if}
{#if ehCriador}
<span class="badge badge-secondary badge-sm">Criador</span
>
{/if}
</div>
<p class="text-sm text-base-content/60 truncate">
{participante.setor || participante.email || ""}
</p>
</div>
<!-- Ações (apenas para admins) -->
{#if isAdmin && !ehCriador}
<div class="flex items-center gap-1">
{#if ehAdmin}
<button
type="button"
class="btn btn-xs btn-ghost"
onclick={() => rebaixarAdmin(participanteId)}
disabled={isLoading}
title="Rebaixar administrador"
>
{#if isLoading && loading?.includes('rebaixar')}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<ArrowDown class="h-4 w-4" />
{/if}
</button>
{:else}
<button
type="button"
class="btn btn-xs btn-ghost"
onclick={() => promoverAdmin(participanteId)}
disabled={isLoading}
title="Promover a administrador"
>
{#if isLoading && loading?.includes('promover')}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<ArrowUp class="h-4 w-4" />
{/if}
</button>
{/if}
<button
type="button"
class="btn btn-xs btn-error btn-ghost"
onclick={() => removerParticipante(participanteId)}
disabled={isLoading}
title="Remover participante"
>
{#if isLoading && loading?.includes('remover')}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<Trash2 class="h-4 w-4" />
{/if}
</button>
</div>
{/if}
</div>
{/each}
{:else}
<div class="text-base-content/50 py-8 text-center">Nenhum participante encontrado</div>
{/if}
</div>
{:else if activeTab === 'adicionar' && isAdmin}
<!-- Adicionar Participante -->
<div class="relative mb-4">
<input
type="text"
placeholder="Buscar usuários..."
class="input input-bordered w-full pl-10"
bind:value={searchQuery}
/>
<Search class="text-base-content/40 absolute top-1/2 left-3 h-5 w-5 -translate-y-1/2" />
</div>
<!-- Ações (apenas para admins) -->
{#if isAdmin && !ehCriador}
<div class="flex items-center gap-1">
{#if ehAdmin}
<button
type="button"
class="btn btn-xs btn-ghost"
onclick={() => rebaixarAdmin(participanteId)}
disabled={isLoading}
title="Rebaixar administrador"
>
{#if isLoading && loading?.includes("rebaixar")}
<span class="loading loading-spinner loading-xs"
></span>
{:else}
<ArrowDown class="w-4 h-4" />
{/if}
</button>
{:else}
<button
type="button"
class="btn btn-xs btn-ghost"
onclick={() => promoverAdmin(participanteId)}
disabled={isLoading}
title="Promover a administrador"
>
{#if isLoading && loading?.includes("promover")}
<span class="loading loading-spinner loading-xs"
></span>
{:else}
<ArrowUp class="w-4 h-4" />
{/if}
</button>
{/if}
<button
type="button"
class="btn btn-xs btn-error btn-ghost"
onclick={() => removerParticipante(participanteId)}
disabled={isLoading}
title="Remover participante"
>
{#if isLoading && loading?.includes("remover")}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<Trash2 class="w-4 h-4" />
{/if}
</button>
</div>
{/if}
</div>
{/each}
{:else}
<div class="text-center py-8 text-base-content/50">
Nenhum participante encontrado
</div>
{/if}
</div>
{:else if activeTab === "adicionar" && isAdmin}
<!-- Adicionar Participante -->
<div class="mb-4 relative">
<input
type="text"
placeholder="Buscar usuários..."
class="input input-bordered w-full pl-10"
bind:value={searchQuery}
/>
<Search
class="w-5 h-5 absolute left-3 top-1/2 -translate-y-1/2 text-base-content/40"
/>
</div>
<div class="space-y-2">
{#if usuariosFiltrados().length > 0}
{#each usuariosFiltrados() as usuario (String(usuario._id))}
{@const usuarioId = String(usuario._id)}
{@const isLoading = loading?.includes(usuarioId)}
<button
type="button"
class="border-base-300 hover:bg-base-200 flex w-full items-center gap-3 rounded-lg border px-4 py-3 text-left transition-colors"
onclick={() => adicionarParticipante(usuarioId)}
disabled={isLoading}
>
<!-- Avatar -->
<div class="relative shrink-0">
<UserAvatar
avatar={usuario.avatar}
fotoPerfilUrl={usuario.fotoPerfilUrl || usuario.fotoPerfil}
nome={usuario.nome || 'Usuário'}
size="sm"
/>
<div class="absolute right-0 bottom-0">
<UserStatusBadge status={usuario.statusPresenca || 'offline'} size="sm" />
</div>
</div>
<div class="space-y-2">
{#if usuariosFiltrados().length > 0}
{#each usuariosFiltrados() as usuario (String(usuario._id))}
{@const usuarioId = String(usuario._id)}
{@const isLoading = loading?.includes(usuarioId)}
<button
type="button"
class="w-full text-left px-4 py-3 rounded-lg border border-base-300 hover:bg-base-200 transition-colors flex items-center gap-3"
onclick={() => adicionarParticipante(usuarioId)}
disabled={isLoading}
>
<!-- Avatar -->
<div class="relative shrink-0">
<UserAvatar
avatar={usuario.avatar}
fotoPerfilUrl={usuario.fotoPerfilUrl || usuario.fotoPerfil}
nome={usuario.nome || "Usuário"}
size="sm"
/>
<div class="absolute bottom-0 right-0">
<UserStatusBadge
status={usuario.statusPresenca || "offline"}
size="sm"
/>
</div>
</div>
<!-- Info -->
<div class="min-w-0 flex-1">
<p class="text-base-content truncate font-medium">
{usuario.nome || 'Usuário'}
</p>
<p class="text-base-content/60 truncate text-sm">
{usuario.setor || usuario.email || ''}
</p>
</div>
<!-- Info -->
<div class="flex-1 min-w-0">
<p class="font-medium text-base-content truncate">
{usuario.nome || "Usuário"}
</p>
<p class="text-sm text-base-content/60 truncate">
{usuario.setor || usuario.email || ""}
</p>
</div>
<!-- Botão Adicionar -->
{#if isLoading}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<UserPlus class="text-primary h-5 w-5" />
{/if}
</button>
{/each}
{:else}
<div class="text-base-content/50 py-8 text-center">
{searchQuery.trim()
? 'Nenhum usuário encontrado'
: 'Todos os usuários já são participantes'}
</div>
{/if}
</div>
{/if}
</div>
<!-- Botão Adicionar -->
{#if isLoading}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<UserPlus class="w-5 h-5 text-primary" />
{/if}
</button>
{/each}
{:else}
<div class="text-center py-8 text-base-content/50">
{searchQuery.trim()
? "Nenhum usuário encontrado"
: "Todos os usuários já são participantes"}
</div>
{/if}
</div>
{/if}
</div>
<!-- Footer -->
<div class="border-base-300 border-t px-6 py-4">
<button type="button" class="btn btn-block" onclick={onClose}> Fechar </button>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button type="button" onclick={onClose}>fechar</button>
</form>
<!-- Footer -->
<div class="px-6 py-4 border-t border-base-300">
<button type="button" class="btn btn-block" onclick={onClose}>
Fechar
</button>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button type="button" onclick={onClose}>fechar</button>
</form>
</dialog>

View File

@@ -1,269 +1,288 @@
<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 { format } from 'date-fns';
import { ptBR } from 'date-fns/locale';
import { Clock, X, Trash2 } from 'lucide-svelte';
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 { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import { Clock, X, Trash2 } from "lucide-svelte";
interface Props {
conversaId: Id<'conversas'>;
onClose: () => void;
}
interface Props {
conversaId: Id<"conversas">;
onClose: () => void;
}
let { conversaId, onClose }: Props = $props();
let { conversaId, onClose }: Props = $props();
const client = useConvexClient();
const mensagensAgendadas = useQuery(api.chat.obterMensagensAgendadas, {
conversaId
});
const client = useConvexClient();
const mensagensAgendadas = useQuery(api.chat.obterMensagensAgendadas, {
conversaId,
});
let mensagem = $state('');
let data = $state('');
let hora = $state('');
let loading = $state(false);
let mensagem = $state("");
let data = $state("");
let hora = $state("");
let loading = $state(false);
// Rastrear mudanças nas mensagens agendadas
$effect(() => {
console.log('📅 [ScheduleModal] Mensagens agendadas atualizadas:', mensagensAgendadas?.data);
});
// Rastrear mudanças nas mensagens agendadas
$effect(() => {
console.log(
"📅 [ScheduleModal] Mensagens agendadas atualizadas:",
mensagensAgendadas?.data,
);
});
// Definir data/hora mínima (agora)
const now = new Date();
const minDate = format(now, 'yyyy-MM-dd');
const minTime = format(now, 'HH:mm');
// Definir data/hora mínima (agora)
const now = new Date();
const minDate = format(now, "yyyy-MM-dd");
const minTime = format(now, "HH:mm");
function getPreviewText(): string {
if (!data || !hora) return '';
function getPreviewText(): string {
if (!data || !hora) return "";
try {
const dataHora = new Date(`${data}T${hora}`);
return `Será enviada em ${format(dataHora, "dd/MM/yyyy 'às' HH:mm", { locale: ptBR })}`;
} catch {
return '';
}
}
try {
const dataHora = new Date(`${data}T${hora}`);
return `Será enviada em ${format(dataHora, "dd/MM/yyyy 'às' HH:mm", { locale: ptBR })}`;
} catch {
return "";
}
}
async function handleAgendar() {
if (!mensagem.trim() || !data || !hora) {
alert('Preencha todos os campos');
return;
}
async function handleAgendar() {
if (!mensagem.trim() || !data || !hora) {
alert("Preencha todos os campos");
return;
}
try {
loading = true;
const dataHora = new Date(`${data}T${hora}`);
try {
loading = true;
const dataHora = new Date(`${data}T${hora}`);
// Validar data futura
if (dataHora.getTime() <= Date.now()) {
alert('A data e hora devem ser futuras');
return;
}
// Validar data futura
if (dataHora.getTime() <= Date.now()) {
alert("A data e hora devem ser futuras");
return;
}
await client.mutation(api.chat.agendarMensagem, {
conversaId,
conteudo: mensagem.trim(),
agendadaPara: dataHora.getTime()
});
await client.mutation(api.chat.agendarMensagem, {
conversaId,
conteudo: mensagem.trim(),
agendadaPara: dataHora.getTime(),
});
mensagem = '';
data = '';
hora = '';
mensagem = "";
data = "";
hora = "";
// Dar tempo para o Convex processar e recarregar a lista
setTimeout(() => {
alert('Mensagem agendada com sucesso!');
}, 500);
} catch (error) {
console.error('Erro ao agendar mensagem:', error);
alert('Erro ao agendar mensagem');
} finally {
loading = false;
}
}
// Dar tempo para o Convex processar e recarregar a lista
setTimeout(() => {
alert("Mensagem agendada com sucesso!");
}, 500);
} catch (error) {
console.error("Erro ao agendar mensagem:", error);
alert("Erro ao agendar mensagem");
} finally {
loading = false;
}
}
async function handleCancelar(mensagemId: string) {
if (!confirm('Deseja cancelar esta mensagem agendada?')) return;
async function handleCancelar(mensagemId: string) {
if (!confirm("Deseja cancelar esta mensagem agendada?")) return;
try {
await client.mutation(api.chat.cancelarMensagemAgendada, {
mensagemId: mensagemId as any
});
} catch (error) {
console.error('Erro ao cancelar mensagem:', error);
alert('Erro ao cancelar mensagem');
}
}
try {
await client.mutation(api.chat.cancelarMensagemAgendada, {
mensagemId: mensagemId as any,
});
} catch (error) {
console.error("Erro ao cancelar mensagem:", error);
alert("Erro ao cancelar mensagem");
}
}
function formatarDataHora(timestamp: number): string {
try {
return format(new Date(timestamp), "dd/MM/yyyy 'às' HH:mm", {
locale: ptBR
});
} catch {
return 'Data inválida';
}
}
function formatarDataHora(timestamp: number): string {
try {
return format(new Date(timestamp), "dd/MM/yyyy 'às' HH:mm", {
locale: ptBR,
});
} catch {
return "Data inválida";
}
}
</script>
<dialog class="modal modal-open" onclick={(e) => e.target === e.currentTarget && onClose()}>
<div
class="modal-box flex max-h-[90vh] max-w-2xl flex-col p-0"
onclick={(e) => e.stopPropagation()}
>
<!-- Header -->
<div class="border-base-300 flex items-center justify-between border-b px-6 py-4">
<h2 id="modal-title" class="flex items-center gap-2 text-xl font-bold">
<Clock class="text-primary h-5 w-5" />
Agendar Mensagem
</h2>
<button type="button" class="btn btn-sm btn-circle" onclick={onClose} aria-label="Fechar">
<X class="h-5 w-5" />
</button>
</div>
<dialog
class="modal modal-open"
onclick={(e) => e.target === e.currentTarget && onClose()}
>
<div
class="modal-box max-w-2xl max-h-[90vh] flex flex-col p-0"
onclick={(e) => e.stopPropagation()}
>
<!-- Header -->
<div
class="flex items-center justify-between px-6 py-4 border-b border-base-300"
>
<h2 id="modal-title" class="text-xl font-bold flex items-center gap-2">
<Clock class="w-5 h-5 text-primary" />
Agendar Mensagem
</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>
<!-- Content -->
<div class="flex-1 space-y-6 overflow-y-auto p-6">
<!-- Formulário de Agendamento -->
<div class="card bg-base-200">
<div class="card-body">
<h3 class="card-title text-lg">Nova Mensagem Agendada</h3>
<!-- Content -->
<div class="flex-1 overflow-y-auto p-6 space-y-6">
<!-- Formulário de Agendamento -->
<div class="card bg-base-200">
<div class="card-body">
<h3 class="card-title text-lg">Nova Mensagem Agendada</h3>
<div class="form-control">
<label class="label" for="mensagem-input">
<span class="label-text">Mensagem</span>
</label>
<textarea
id="mensagem-input"
class="textarea textarea-bordered h-24"
placeholder="Digite a mensagem..."
bind:value={mensagem}
maxlength="500"
aria-describedby="char-count"
></textarea>
<div class="label">
<span id="char-count" class="label-text-alt">{mensagem.length}/500</span>
</div>
</div>
<div class="form-control">
<label class="label" for="mensagem-input">
<span class="label-text">Mensagem</span>
</label>
<textarea
id="mensagem-input"
class="textarea textarea-bordered h-24"
placeholder="Digite a mensagem..."
bind:value={mensagem}
maxlength="500"
aria-describedby="char-count"
></textarea>
<div class="label">
<span id="char-count" class="label-text-alt"
>{mensagem.length}/500</span
>
</div>
</div>
<div class="grid gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label" for="data-input">
<span class="label-text">Data</span>
</label>
<input
id="data-input"
type="date"
class="input input-bordered"
bind:value={data}
min={minDate}
/>
</div>
<div class="grid md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label" for="data-input">
<span class="label-text">Data</span>
</label>
<input
id="data-input"
type="date"
class="input input-bordered"
bind:value={data}
min={minDate}
/>
</div>
<div class="form-control">
<label class="label" for="hora-input">
<span class="label-text">Hora</span>
</label>
<input
id="hora-input"
type="time"
class="input input-bordered"
bind:value={hora}
min={data === minDate ? minTime : undefined}
/>
</div>
</div>
<div class="form-control">
<label class="label" for="hora-input">
<span class="label-text">Hora</span>
</label>
<input
id="hora-input"
type="time"
class="input input-bordered"
bind:value={hora}
min={data === minDate ? minTime : undefined}
/>
</div>
</div>
{#if getPreviewText()}
<div class="alert alert-info">
<Clock class="h-6 w-6" />
<span>{getPreviewText()}</span>
</div>
{/if}
{#if getPreviewText()}
<div class="alert alert-info">
<Clock class="w-6 h-6" />
<span>{getPreviewText()}</span>
</div>
{/if}
<div class="card-actions justify-end">
<!-- Botão AGENDAR ultra moderno -->
<button
type="button"
class="group relative overflow-hidden rounded-xl px-6 py-3 font-bold text-white transition-all duration-300 disabled:cursor-not-allowed disabled:opacity-50"
style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); box-shadow: 0 8px 24px -4px rgba(102, 126, 234, 0.4);"
onclick={handleAgendar}
disabled={loading || !mensagem.trim() || !data || !hora}
>
<!-- Efeito de brilho no hover -->
<div
class="absolute inset-0 bg-white/0 transition-colors duration-300 group-hover:bg-white/10"
></div>
<div class="card-actions justify-end">
<!-- Botão AGENDAR ultra moderno -->
<button
type="button"
class="relative px-6 py-3 rounded-xl font-bold text-white overflow-hidden transition-all duration-300 group disabled:opacity-50 disabled:cursor-not-allowed"
style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); box-shadow: 0 8px 24px -4px rgba(102, 126, 234, 0.4);"
onclick={handleAgendar}
disabled={loading || !mensagem.trim() || !data || !hora}
>
<!-- Efeito de brilho no hover -->
<div
class="absolute inset-0 bg-white/0 group-hover:bg-white/10 transition-colors duration-300"
></div>
<div class="relative z-10 flex items-center gap-2">
{#if loading}
<span class="loading loading-spinner loading-sm"></span>
<span>Agendando...</span>
{:else}
<Clock class="h-5 w-5 transition-transform group-hover:scale-110" />
<span class="transition-transform group-hover:scale-105">Agendar</span>
{/if}
</div>
</button>
</div>
</div>
</div>
<div class="relative z-10 flex items-center gap-2">
{#if loading}
<span class="loading loading-spinner loading-sm"></span>
<span>Agendando...</span>
{:else}
<Clock
class="w-5 h-5 group-hover:scale-110 transition-transform"
/>
<span class="group-hover:scale-105 transition-transform"
>Agendar</span
>
{/if}
</div>
</button>
</div>
</div>
</div>
<!-- Lista de Mensagens Agendadas -->
<div class="card bg-base-200">
<div class="card-body">
<h3 class="card-title text-lg">Mensagens Agendadas</h3>
<!-- Lista de Mensagens Agendadas -->
<div class="card bg-base-200">
<div class="card-body">
<h3 class="card-title text-lg">Mensagens Agendadas</h3>
{#if mensagensAgendadas?.data && mensagensAgendadas.data.length > 0}
<div class="space-y-3">
{#each mensagensAgendadas.data as msg (msg._id)}
<div class="bg-base-100 flex items-start gap-3 rounded-lg p-3">
<div class="mt-1 shrink-0">
<Clock class="text-primary h-5 w-5" />
</div>
{#if mensagensAgendadas?.data && mensagensAgendadas.data.length > 0}
<div class="space-y-3">
{#each mensagensAgendadas.data as msg (msg._id)}
<div class="flex items-start gap-3 p-3 bg-base-100 rounded-lg">
<div class="shrink-0 mt-1">
<Clock class="w-5 h-5 text-primary" />
</div>
<div class="min-w-0 flex-1">
<p class="text-base-content/80 text-sm font-medium">
{formatarDataHora(msg.agendadaPara || 0)}
</p>
<p class="text-base-content mt-1 line-clamp-2 text-sm">
{msg.conteudo}
</p>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-base-content/80">
{formatarDataHora(msg.agendadaPara || 0)}
</p>
<p class="text-sm text-base-content mt-1 line-clamp-2">
{msg.conteudo}
</p>
</div>
<!-- Botão cancelar moderno -->
<button
type="button"
class="group relative flex h-9 w-9 items-center justify-center overflow-hidden rounded-lg transition-all duration-300"
style="background: rgba(239, 68, 68, 0.1); border: 1px solid rgba(239, 68, 68, 0.2);"
onclick={() => handleCancelar(msg._id)}
aria-label="Cancelar"
>
<div
class="bg-error/0 group-hover:bg-error/20 absolute inset-0 transition-colors duration-300"
></div>
<Trash2
class="text-error relative z-10 h-5 w-5 transition-transform group-hover:scale-110"
/>
</button>
</div>
{/each}
</div>
{:else if !mensagensAgendadas?.data}
<div class="flex items-center justify-center py-8">
<span class="loading loading-spinner loading-lg"></span>
</div>
{:else}
<div class="text-base-content/50 py-8 text-center">
<Clock class="mx-auto mb-2 h-12 w-12 opacity-50" />
<p class="text-sm">Nenhuma mensagem agendada</p>
</div>
{/if}
</div>
</div>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button type="button" onclick={onClose}>fechar</button>
</form>
<!-- Botão cancelar moderno -->
<button
type="button"
class="flex items-center justify-center w-9 h-9 rounded-lg transition-all duration-300 group relative overflow-hidden"
style="background: rgba(239, 68, 68, 0.1); border: 1px solid rgba(239, 68, 68, 0.2);"
onclick={() => handleCancelar(msg._id)}
aria-label="Cancelar"
>
<div
class="absolute inset-0 bg-error/0 group-hover:bg-error/20 transition-colors duration-300"
></div>
<Trash2
class="w-5 h-5 text-error relative z-10 group-hover:scale-110 transition-transform"
/>
</button>
</div>
{/each}
</div>
{:else if !mensagensAgendadas?.data}
<div class="flex items-center justify-center py-8">
<span class="loading loading-spinner loading-lg"></span>
</div>
{:else}
<div class="text-center py-8 text-base-content/50">
<Clock class="w-12 h-12 mx-auto mb-2 opacity-50" />
<p class="text-sm">Nenhuma mensagem agendada</p>
</div>
{/if}
</div>
</div>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button type="button" onclick={onClose}>fechar</button>
</form>
</dialog>

View File

@@ -1,36 +1,41 @@
<script lang="ts">
import { getAvatarUrl as generateAvatarUrl } from '$lib/utils/avatarGenerator';
import { getAvatarUrl as generateAvatarUrl } from "$lib/utils/avatarGenerator";
interface Props {
avatar?: string;
fotoPerfilUrl?: string | null;
nome: string;
size?: "xs" | "sm" | "md" | "lg";
}
interface Props {
avatar?: string;
fotoPerfilUrl?: string | null;
nome: string;
size?: 'xs' | 'sm' | 'md' | 'lg';
}
let { avatar, fotoPerfilUrl, nome, size = "md" }: Props = $props();
let { avatar, fotoPerfilUrl, nome, size = 'md' }: Props = $props();
const sizeClasses = {
xs: "w-8 h-8",
sm: "w-10 h-10",
md: "w-12 h-12",
lg: "w-16 h-16",
};
const sizeClasses = {
xs: 'w-8 h-8',
sm: 'w-10 h-10',
md: 'w-12 h-12',
lg: 'w-16 h-16'
};
function getAvatarUrl(avatarId: string): string {
// Usar gerador local ao invés da API externa
return generateAvatarUrl(avatarId);
}
function getAvatarUrl(avatarId: string): string {
// Usar gerador local ao invés da API externa
return generateAvatarUrl(avatarId);
}
const avatarUrlToShow = $derived(() => {
if (fotoPerfilUrl) return fotoPerfilUrl;
if (avatar) return getAvatarUrl(avatar);
return getAvatarUrl(nome); // Fallback usando o nome
});
const avatarUrlToShow = $derived(() => {
if (fotoPerfilUrl) return fotoPerfilUrl;
if (avatar) return getAvatarUrl(avatar);
return getAvatarUrl(nome); // Fallback usando o nome
});
</script>
<div class="avatar">
<div class={`${sizeClasses[size]} bg-base-200 overflow-hidden rounded-full`}>
<img src={avatarUrlToShow()} alt={`Avatar de ${nome}`} class="h-full w-full object-cover" />
</div>
<div class={`${sizeClasses[size]} rounded-full bg-base-200 overflow-hidden`}>
<img
src={avatarUrlToShow()}
alt={`Avatar de ${nome}`}
class="w-full h-full object-cover"
/>
</div>
</div>