Files
sgse-app/apps/web/src/lib/components/chat/ChatWindow.svelte
deyvisonwanderley 6166043735 feat: enhance ErrorModal and chat components with new features and improvements
- Refactored ErrorModal to utilize a dialog element for better accessibility and user experience, including a close button with an icon.
- Updated chat components to improve participant display and message read status, enhancing user engagement and clarity.
- Introduced loading indicators for user and conversation data in SalaReuniaoManager to improve responsiveness during data fetching.
- Enhanced message handling in MessageList to indicate whether messages have been read, providing users with better feedback on message status.
- Improved overall structure and styling across various components for consistency and maintainability.
2025-11-05 14:05:52 -03:00

444 lines
17 KiB
Svelte

<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 { authStore } from "$lib/stores/auth.svelte";
import { Bell, X, ArrowLeft, LogOut, MoreVertical, Users, Clock, XCircle } from "lucide-svelte";
interface Props {
conversaId: string;
}
let { conversaId }: Props = $props();
const client = useConvexClient();
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 any });
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;
}
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 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 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;
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 any,
});
if (resultado.sucesso) {
voltarParaLista();
} else {
alert(resultado.erro || "Erro ao sair da conversa");
}
} catch (error: any) {
console.error("Erro ao sair da conversa:", error);
alert(error.message || "Erro ao sair da conversa");
}
}
</script>
<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>
<!-- Avatar e Info -->
<div class="relative flex-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>
<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>
<!-- 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ã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 any,
});
if (resultado.sucesso) {
alert("Reunião encerrada com sucesso!");
voltarParaLista();
} else {
alert(resultado.erro || "Erro ao encerrar reunião");
}
} catch (error: any) {
alert(error.message || "Erro ao encerrar reunião");
}
showAdminMenu = false;
})();
}}
>
<XCircle class="w-4 h-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>
<!-- Mensagens -->
<div class="flex-1 overflow-hidden min-h-0">
<MessageList conversaId={conversaId as Id<"conversas">} />
</div>
<!-- Input -->
<div class="border-t border-base-300 flex-shrink-0">
<MessageInput conversaId={conversaId as Id<"conversas">} />
</div>
</div>
<!-- Modal de Agendamento -->
{#if showScheduleModal}
<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}
<!-- 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="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;
}
try {
const resultado = await client.mutation(api.chat.enviarNotificacaoReuniao, {
conversaId: conversaId as any,
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: any) {
alert(error.message || "Erro ao enviar notificação");
}
}}
>
<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}