feat: implement comprehensive chat system with user presence management, notification handling, and avatar integration; enhance UI components for improved user experience
This commit is contained in:
194
apps/web/src/lib/components/chat/ChatList.svelte
Normal file
194
apps/web/src/lib/components/chat/ChatList.svelte
Normal file
@@ -0,0 +1,194 @@
|
||||
<script lang="ts">
|
||||
import { useQuery, useConvexClient } from "convex-svelte";
|
||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||
import { abrirConversa } from "$lib/stores/chatStore";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { ptBR } from "date-fns/locale";
|
||||
import UserStatusBadge from "./UserStatusBadge.svelte";
|
||||
import UserAvatar from "./UserAvatar.svelte";
|
||||
|
||||
const client = useConvexClient();
|
||||
|
||||
// Buscar todos os usuários para o chat
|
||||
const usuarios = useQuery(api.usuarios.listarParaChat, {});
|
||||
|
||||
// Buscar o perfil do usuário logado
|
||||
const meuPerfil = useQuery(api.usuarios.obterPerfil, {});
|
||||
|
||||
let searchQuery = $state("");
|
||||
|
||||
const usuariosFiltrados = $derived.by(() => {
|
||||
if (!usuarios || !Array.isArray(usuarios) || !meuPerfil) return [];
|
||||
|
||||
// Filtrar o próprio usuário da lista
|
||||
let listaFiltrada = usuarios.filter((u: any) => u._id !== meuPerfil._id);
|
||||
|
||||
// Aplicar busca por nome/email/matrícula
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
listaFiltrada = listaFiltrada.filter((u: any) =>
|
||||
u.nome?.toLowerCase().includes(query) ||
|
||||
u.email?.toLowerCase().includes(query) ||
|
||||
u.matricula?.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
// Ordenar: Online primeiro, depois por nome
|
||||
return listaFiltrada.sort((a: any, b: any) => {
|
||||
const statusOrder = { online: 0, ausente: 1, externo: 2, em_reuniao: 3, offline: 4 };
|
||||
const statusA = statusOrder[a.statusPresenca as keyof typeof statusOrder] ?? 4;
|
||||
const statusB = statusOrder[b.statusPresenca as keyof typeof statusOrder] ?? 4;
|
||||
|
||||
if (statusA !== statusB) return statusA - statusB;
|
||||
return a.nome.localeCompare(b.nome);
|
||||
});
|
||||
});
|
||||
|
||||
function formatarTempo(timestamp: number | undefined): string {
|
||||
if (!timestamp) return "";
|
||||
try {
|
||||
return formatDistanceToNow(new Date(timestamp), {
|
||||
addSuffix: true,
|
||||
locale: ptBR,
|
||||
});
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClickUsuario(usuario: any) {
|
||||
try {
|
||||
// Criar ou buscar conversa individual com este usuário
|
||||
const conversaId = await client.mutation(api.chat.criarOuBuscarConversaIndividual, {
|
||||
outroUsuarioId: usuario._id,
|
||||
});
|
||||
|
||||
// Abrir a conversa
|
||||
abrirConversa(conversaId as any);
|
||||
} catch (error) {
|
||||
console.error("Erro ao abrir conversa:", error);
|
||||
alert("Erro ao abrir conversa");
|
||||
}
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Search bar -->
|
||||
<div class="p-4 border-b border-base-300">
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar usuários (nome, email, matrícula)..."
|
||||
class="input input-bordered w-full pl-10"
|
||||
bind:value={searchQuery}
|
||||
/>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="w-5 h-5 absolute left-3 top-1/2 -translate-y-1/2 text-base-content/50"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Título da Lista -->
|
||||
<div class="p-4 border-b border-base-300 bg-base-200">
|
||||
<h3 class="font-semibold text-sm text-base-content/70 uppercase tracking-wide">
|
||||
Usuários do Sistema ({usuariosFiltrados.length})
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<!-- Lista de usuários -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
{#if usuarios && 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"
|
||||
onclick={() => handleClickUsuario(usuario)}
|
||||
>
|
||||
<!-- Avatar -->
|
||||
<div class="relative flex-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="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}
|
||||
<!-- 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}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
190
apps/web/src/lib/components/chat/ChatWidget.svelte
Normal file
190
apps/web/src/lib/components/chat/ChatWidget.svelte
Normal file
@@ -0,0 +1,190 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
chatAberto,
|
||||
chatMinimizado,
|
||||
conversaAtiva,
|
||||
fecharChat,
|
||||
minimizarChat,
|
||||
maximizarChat,
|
||||
abrirChat,
|
||||
} from "$lib/stores/chatStore";
|
||||
import { useQuery } from "convex-svelte";
|
||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||
import ChatList from "./ChatList.svelte";
|
||||
import ChatWindow from "./ChatWindow.svelte";
|
||||
|
||||
const count = useQuery(api.chat.contarNotificacoesNaoLidas, {});
|
||||
|
||||
let isOpen = $state(false);
|
||||
let isMinimized = $state(false);
|
||||
let activeConversation = $state<string | null>(null);
|
||||
|
||||
// Sincronizar com stores
|
||||
$effect(() => {
|
||||
isOpen = $chatAberto;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
isMinimized = $chatMinimizado;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
activeConversation = $conversaAtiva;
|
||||
});
|
||||
|
||||
function handleToggle() {
|
||||
if (isOpen && !isMinimized) {
|
||||
minimizarChat();
|
||||
} else {
|
||||
abrirChat();
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
fecharChat();
|
||||
}
|
||||
|
||||
function handleMinimize() {
|
||||
minimizarChat();
|
||||
}
|
||||
|
||||
function handleMaximize() {
|
||||
maximizarChat();
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Botão flutuante (quando fechado ou minimizado) -->
|
||||
{#if !isOpen || isMinimized}
|
||||
<button
|
||||
type="button"
|
||||
class="fixed bottom-6 right-6 btn btn-circle btn-primary btn-lg shadow-2xl z-50 hover:scale-110 transition-transform"
|
||||
onclick={handleToggle}
|
||||
aria-label="Abrir chat"
|
||||
>
|
||||
<!-- Ícone de chat -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="w-7 h-7"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M8.625 12a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 0 1-2.555-.337A5.972 5.972 0 0 1 5.41 20.97a5.969 5.969 0 0 1-.474-.065 4.48 4.48 0 0 0 .978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25Z"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<!-- Badge de contador -->
|
||||
{#if count && count > 0}
|
||||
<span
|
||||
class="absolute -top-1 -right-1 flex h-6 w-6 items-center justify-center rounded-full bg-error text-error-content text-xs font-bold"
|
||||
>
|
||||
{count > 9 ? "9+" : count}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Janela do Chat -->
|
||||
{#if isOpen && !isMinimized}
|
||||
<div
|
||||
class="fixed bottom-6 right-6 z-50 flex flex-col bg-base-100 rounded-2xl shadow-2xl border border-base-300 overflow-hidden
|
||||
w-[400px] h-[600px] max-w-[calc(100vw-3rem)] max-h-[calc(100vh-3rem)]
|
||||
md:w-[400px] md:h-[600px]
|
||||
sm:w-full sm:h-full sm:bottom-0 sm:right-0 sm:rounded-none sm:max-w-full sm:max-h-full"
|
||||
style="animation: slideIn 0.3s ease-out;"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex items-center justify-between px-4 py-3 bg-primary text-primary-content border-b border-primary-focus"
|
||||
>
|
||||
<h2 class="text-lg font-semibold flex items-center gap-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="w-6 h-6"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M8.625 12a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 0 1-2.555-.337A5.972 5.972 0 0 1 5.41 20.97a5.969 5.969 0 0 1-.474-.065 4.48 4.48 0 0 0 .978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25Z"
|
||||
/>
|
||||
</svg>
|
||||
Chat
|
||||
</h2>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- Botão minimizar -->
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm btn-circle"
|
||||
onclick={handleMinimize}
|
||||
aria-label="Minimizar"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Botão fechar -->
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm btn-circle"
|
||||
onclick={handleClose}
|
||||
aria-label="Fechar"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M6 18 18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Conteúdo -->
|
||||
<div class="flex-1 overflow-hidden">
|
||||
{#if !activeConversation}
|
||||
<ChatList />
|
||||
{:else}
|
||||
<ChatWindow conversaId={activeConversation} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px) scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
169
apps/web/src/lib/components/chat/ChatWindow.svelte
Normal file
169
apps/web/src/lib/components/chat/ChatWindow.svelte
Normal file
@@ -0,0 +1,169 @@
|
||||
<script lang="ts">
|
||||
import { useQuery } 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 ScheduleMessageModal from "./ScheduleMessageModal.svelte";
|
||||
|
||||
interface Props {
|
||||
conversaId: string;
|
||||
}
|
||||
|
||||
let { conversaId }: Props = $props();
|
||||
|
||||
let showScheduleModal = $state(false);
|
||||
|
||||
const conversas = useQuery(api.chat.listarConversas, {});
|
||||
|
||||
const conversa = $derived(() => {
|
||||
if (!conversas) return null;
|
||||
return conversas.find((c: any) => c._id === conversaId);
|
||||
});
|
||||
|
||||
function getNomeConversa(): string {
|
||||
const c = conversa();
|
||||
if (!c) return "Carregando...";
|
||||
if (c.tipo === "grupo") {
|
||||
return c.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(): any {
|
||||
const c = conversa();
|
||||
if (c && c.tipo === "individual" && c.outroUsuario) {
|
||||
return c.outroUsuario.statusPresenca || "offline";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getStatusMensagem(): string | null {
|
||||
const c = conversa();
|
||||
if (c && c.tipo === "individual" && c.outroUsuario) {
|
||||
return c.outroUsuario.statusMensagem || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-3 px-4 py-3 border-b border-base-300 bg-base-200">
|
||||
<!-- Botão Voltar -->
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm btn-circle"
|
||||
onclick={voltarParaLista}
|
||||
aria-label="Voltar"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Avatar e Info -->
|
||||
<div class="relative flex-shrink-0">
|
||||
<div
|
||||
class="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center text-xl"
|
||||
>
|
||||
{getAvatarConversa()}
|
||||
</div>
|
||||
{#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>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Botões de ação -->
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- Botão Agendar -->
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm btn-circle"
|
||||
onclick={() => (showScheduleModal = true)}
|
||||
aria-label="Agendar mensagem"
|
||||
title="Agendar mensagem"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mensagens -->
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<MessageList conversaId={conversaId as any} />
|
||||
</div>
|
||||
|
||||
<!-- Input -->
|
||||
<div class="border-t border-base-300">
|
||||
<MessageInput conversaId={conversaId as any} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal de Agendamento -->
|
||||
{#if showScheduleModal}
|
||||
<ScheduleMessageModal
|
||||
conversaId={conversaId as any}
|
||||
onClose={() => (showScheduleModal = false)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
208
apps/web/src/lib/components/chat/MessageInput.svelte
Normal file
208
apps/web/src/lib/components/chat/MessageInput.svelte
Normal file
@@ -0,0 +1,208 @@
|
||||
<script lang="ts">
|
||||
import { useConvexClient } from "convex-svelte";
|
||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||
import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
interface Props {
|
||||
conversaId: Id<"conversas">;
|
||||
}
|
||||
|
||||
let { conversaId }: Props = $props();
|
||||
|
||||
const client = useConvexClient();
|
||||
|
||||
let mensagem = $state("");
|
||||
let textarea: HTMLTextAreaElement;
|
||||
let enviando = $state(false);
|
||||
let uploadingFile = $state(false);
|
||||
let digitacaoTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Auto-resize do textarea
|
||||
function handleInput() {
|
||||
if (textarea) {
|
||||
textarea.style.height = "auto";
|
||||
textarea.style.height = Math.min(textarea.scrollHeight, 120) + "px";
|
||||
}
|
||||
|
||||
// Indicador de digitação (debounce de 1s)
|
||||
if (digitacaoTimeout) {
|
||||
clearTimeout(digitacaoTimeout);
|
||||
}
|
||||
digitacaoTimeout = setTimeout(() => {
|
||||
if (mensagem.trim()) {
|
||||
client.mutation(api.chat.indicarDigitacao, { conversaId });
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
async function handleEnviar() {
|
||||
const texto = mensagem.trim();
|
||||
if (!texto || enviando) return;
|
||||
|
||||
try {
|
||||
enviando = true;
|
||||
await client.mutation(api.chat.enviarMensagem, {
|
||||
conversaId,
|
||||
conteudo: texto,
|
||||
tipo: "texto",
|
||||
});
|
||||
mensagem = "";
|
||||
if (textarea) {
|
||||
textarea.style.height = "auto";
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Erro ao enviar mensagem:", error);
|
||||
alert("Erro ao enviar mensagem");
|
||||
} finally {
|
||||
enviando = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
// Enter sem Shift = enviar
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleEnviar();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFileUpload(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Validar tamanho (max 10MB)
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
alert("Arquivo muito grande. O tamanho máximo é 10MB.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
uploadingFile = true;
|
||||
|
||||
// 1. Obter upload URL
|
||||
const uploadUrl = await client.mutation(api.chat.uploadArquivoChat, { conversaId });
|
||||
|
||||
// 2. Upload do arquivo
|
||||
const result = await fetch(uploadUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": file.type },
|
||||
body: file,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
throw new Error("Falha no upload");
|
||||
}
|
||||
|
||||
const { storageId } = await result.json();
|
||||
|
||||
// 3. Enviar mensagem com o arquivo
|
||||
const tipo = file.type.startsWith("image/") ? "imagem" : "arquivo";
|
||||
await client.mutation(api.chat.enviarMensagem, {
|
||||
conversaId,
|
||||
conteudo: tipo === "imagem" ? "" : file.name,
|
||||
tipo: tipo as any,
|
||||
arquivoId: storageId,
|
||||
arquivoNome: file.name,
|
||||
arquivoTamanho: file.size,
|
||||
arquivoTipo: file.type,
|
||||
});
|
||||
|
||||
// Limpar input
|
||||
input.value = "";
|
||||
} catch (error) {
|
||||
console.error("Erro ao fazer upload:", error);
|
||||
alert("Erro ao enviar arquivo");
|
||||
} finally {
|
||||
uploadingFile = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (textarea) {
|
||||
textarea.focus();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="p-4">
|
||||
<div class="flex items-end gap-2">
|
||||
<!-- Botão de anexar arquivo -->
|
||||
<label class="btn btn-ghost btn-sm btn-circle flex-shrink-0">
|
||||
<input
|
||||
type="file"
|
||||
class="hidden"
|
||||
onchange={handleFileUpload}
|
||||
disabled={uploadingFile || enviando}
|
||||
accept="*/*"
|
||||
/>
|
||||
{#if uploadingFile}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{:else}
|
||||
<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"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="m18.375 12.739-7.693 7.693a4.5 4.5 0 0 1-6.364-6.364l10.94-10.94A3 3 0 1 1 19.5 7.372L8.552 18.32m.009-.01-.01.01m5.699-9.941-7.81 7.81a1.5 1.5 0 0 0 2.112 2.13"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</label>
|
||||
|
||||
<!-- Textarea -->
|
||||
<div class="flex-1 relative">
|
||||
<textarea
|
||||
bind:this={textarea}
|
||||
bind:value={mensagem}
|
||||
oninput={handleInput}
|
||||
onkeydown={handleKeyDown}
|
||||
placeholder="Digite uma mensagem..."
|
||||
class="textarea textarea-bordered w-full resize-none min-h-[44px] max-h-[120px] pr-10"
|
||||
rows="1"
|
||||
disabled={enviando || uploadingFile}
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Botão de enviar -->
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-circle flex-shrink-0"
|
||||
onclick={handleEnviar}
|
||||
disabled={!mensagem.trim() || enviando || uploadingFile}
|
||||
aria-label="Enviar"
|
||||
>
|
||||
{#if enviando}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{:else}
|
||||
<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"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M6 12 3.269 3.125A59.769 59.769 0 0 1 21.485 12 59.768 59.768 0 0 1 3.27 20.875L5.999 12Zm0 0h7.5"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Informação sobre atalhos -->
|
||||
<p class="text-xs text-base-content/50 mt-2 text-center">
|
||||
Pressione Enter para enviar, Shift+Enter para quebrar linha
|
||||
</p>
|
||||
</div>
|
||||
|
||||
253
apps/web/src/lib/components/chat/MessageList.svelte
Normal file
253
apps/web/src/lib/components/chat/MessageList.svelte
Normal file
@@ -0,0 +1,253 @@
|
||||
<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 { onMount, tick } from "svelte";
|
||||
|
||||
interface Props {
|
||||
conversaId: Id<"conversas">;
|
||||
}
|
||||
|
||||
let { conversaId }: Props = $props();
|
||||
|
||||
const client = useConvexClient();
|
||||
const mensagens = useQuery(api.chat.obterMensagens, { conversaId, limit: 50 });
|
||||
const digitando = useQuery(api.chat.obterDigitando, { conversaId });
|
||||
|
||||
let messagesContainer: HTMLDivElement;
|
||||
let shouldScrollToBottom = true;
|
||||
|
||||
// Auto-scroll para a última mensagem
|
||||
$effect(() => {
|
||||
if (mensagens && shouldScrollToBottom && messagesContainer) {
|
||||
tick().then(() => {
|
||||
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Marcar como lida quando mensagens carregam
|
||||
$effect(() => {
|
||||
if (mensagens && mensagens.length > 0) {
|
||||
const ultimaMensagem = mensagens[mensagens.length - 1];
|
||||
client.mutation(api.chat.marcarComoLida, {
|
||||
conversaId,
|
||||
mensagemId: ultimaMensagem._id as any,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function formatarDataMensagem(timestamp: number): string {
|
||||
try {
|
||||
return format(new Date(timestamp), "HH:mm", { locale: ptBR });
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function formatarDiaMensagem(timestamp: number): string {
|
||||
try {
|
||||
return format(new Date(timestamp), "dd/MM/yyyy", { locale: ptBR });
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function agruparMensagensPorDia(msgs: any[]): Record<string, any[]> {
|
||||
const grupos: Record<string, any[]> = {};
|
||||
for (const msg of msgs) {
|
||||
const dia = formatarDiaMensagem(msg.enviadaEm);
|
||||
if (!grupos[dia]) {
|
||||
grupos[dia] = [];
|
||||
}
|
||||
grupos[dia].push(msg);
|
||||
}
|
||||
return grupos;
|
||||
}
|
||||
|
||||
function handleScroll(e: Event) {
|
||||
const target = e.target as HTMLDivElement;
|
||||
const isAtBottom =
|
||||
target.scrollHeight - target.scrollTop - target.clientHeight < 100;
|
||||
shouldScrollToBottom = isAtBottom;
|
||||
}
|
||||
|
||||
async function handleReagir(mensagemId: string, emoji: string) {
|
||||
await client.mutation(api.chat.reagirMensagem, {
|
||||
mensagemId: mensagemId as any,
|
||||
emoji,
|
||||
});
|
||||
}
|
||||
|
||||
function getEmojisReacao(mensagem: any): Array<{ emoji: string; count: number }> {
|
||||
if (!mensagem.reagiuPor || mensagem.reagiuPor.length === 0) return [];
|
||||
|
||||
const emojiMap: Record<string, number> = {};
|
||||
for (const reacao of mensagem.reagiuPor) {
|
||||
emojiMap[reacao.emoji] = (emojiMap[reacao.emoji] || 0) + 1;
|
||||
}
|
||||
|
||||
return Object.entries(emojiMap).map(([emoji, count]) => ({ emoji, count }));
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="h-full overflow-y-auto px-4 py-4 bg-base-100"
|
||||
bind:this={messagesContainer}
|
||||
onscroll={handleScroll}
|
||||
>
|
||||
{#if mensagens && mensagens.length > 0}
|
||||
{@const gruposPorDia = agruparMensagensPorDia(mensagens)}
|
||||
{#each Object.entries(gruposPorDia) as [dia, mensagensDia]}
|
||||
<!-- Separador de dia -->
|
||||
<div class="flex items-center justify-center my-4">
|
||||
<div class="px-3 py-1 rounded-full bg-base-300 text-base-content/70 text-xs">
|
||||
{dia}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mensagens do dia -->
|
||||
{#each mensagensDia as mensagem (mensagem._id)}
|
||||
{@const isMinha = mensagem.remetente?._id === mensagens[0]?.remetente?._id}
|
||||
<div class={`flex mb-4 ${isMinha ? "justify-end" : "justify-start"}`}>
|
||||
<div class={`max-w-[75%] ${isMinha ? "items-end" : "items-start"}`}>
|
||||
<!-- Nome do remetente (apenas se não for minha) -->
|
||||
{#if !isMinha}
|
||||
<p class="text-xs text-base-content/60 mb-1 px-3">
|
||||
{mensagem.remetente?.nome || "Usuário"}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Balão da mensagem -->
|
||||
<div
|
||||
class={`rounded-2xl px-4 py-2 ${
|
||||
isMinha
|
||||
? "bg-primary text-primary-content rounded-br-sm"
|
||||
: "bg-base-200 text-base-content rounded-bl-sm"
|
||||
}`}
|
||||
>
|
||||
{#if mensagem.deletada}
|
||||
<p class="text-sm italic opacity-70">Mensagem deletada</p>
|
||||
{:else if mensagem.tipo === "texto"}
|
||||
<p class="text-sm whitespace-pre-wrap break-words">{mensagem.conteudo}</p>
|
||||
{:else if mensagem.tipo === "imagem"}
|
||||
<div class="mb-2">
|
||||
<img
|
||||
src={mensagem.arquivoUrl}
|
||||
alt={mensagem.arquivoNome}
|
||||
class="max-w-full rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
{#if mensagem.conteudo}
|
||||
<p class="text-sm whitespace-pre-wrap break-words">{mensagem.conteudo}</p>
|
||||
{/if}
|
||||
{:else if mensagem.tipo === "arquivo"}
|
||||
<a
|
||||
href={mensagem.arquivoUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center gap-2 hover:opacity-80"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"
|
||||
/>
|
||||
</svg>
|
||||
<div class="text-sm">
|
||||
<p class="font-medium">{mensagem.arquivoNome}</p>
|
||||
{#if mensagem.arquivoTamanho}
|
||||
<p class="text-xs opacity-70">
|
||||
{(mensagem.arquivoTamanho / 1024 / 1024).toFixed(2)} MB
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
<!-- Reações -->
|
||||
{#if !mensagem.deletada && getEmojisReacao(mensagem).length > 0}
|
||||
<div class="flex items-center gap-1 mt-2">
|
||||
{#each getEmojisReacao(mensagem) as reacao}
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs px-2 py-0.5 rounded-full bg-base-300/50 hover:bg-base-300"
|
||||
onclick={() => handleReagir(mensagem._id, reacao.emoji)}
|
||||
>
|
||||
{reacao.emoji} {reacao.count}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Timestamp -->
|
||||
<p
|
||||
class={`text-xs text-base-content/50 mt-1 px-3 ${isMinha ? "text-right" : "text-left"}`}
|
||||
>
|
||||
{formatarDataMensagem(mensagem.enviadaEm)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/each}
|
||||
|
||||
<!-- Indicador de digitação -->
|
||||
{#if digitando && digitando.length > 0}
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="w-2 h-2 rounded-full bg-base-content/50 animate-bounce"></div>
|
||||
<div
|
||||
class="w-2 h-2 rounded-full bg-base-content/50 animate-bounce"
|
||||
style="animation-delay: 0.1s;"
|
||||
></div>
|
||||
<div
|
||||
class="w-2 h-2 rounded-full bg-base-content/50 animate-bounce"
|
||||
style="animation-delay: 0.2s;"
|
||||
></div>
|
||||
</div>
|
||||
<p class="text-xs text-base-content/60">
|
||||
{digitando.map((u: any) => u.nome).join(", ")} {digitando.length === 1
|
||||
? "está digitando"
|
||||
: "estão digitando"}...
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if !mensagens}
|
||||
<!-- Loading -->
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Vazio -->
|
||||
<div class="flex flex-col items-center justify-center h-full text-center">
|
||||
<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="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-base-content/70">Nenhuma mensagem ainda</p>
|
||||
<p class="text-sm text-base-content/50 mt-1">Envie a primeira mensagem!</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
254
apps/web/src/lib/components/chat/NewConversationModal.svelte
Normal file
254
apps/web/src/lib/components/chat/NewConversationModal.svelte
Normal file
@@ -0,0 +1,254 @@
|
||||
<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";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { onClose }: Props = $props();
|
||||
|
||||
const client = useConvexClient();
|
||||
const usuarios = useQuery(api.chat.listarTodosUsuarios, {});
|
||||
|
||||
let activeTab = $state<"individual" | "grupo">("individual");
|
||||
let searchQuery = $state("");
|
||||
let selectedUsers = $state<string[]>([]);
|
||||
let groupName = $state("");
|
||||
let loading = $state(false);
|
||||
|
||||
const usuariosFiltrados = $derived(() => {
|
||||
if (!usuarios) return [];
|
||||
if (!searchQuery.trim()) return usuarios;
|
||||
|
||||
const query = searchQuery.toLowerCase();
|
||||
return usuarios.filter((u: any) =>
|
||||
u.nome.toLowerCase().includes(query) ||
|
||||
u.email.toLowerCase().includes(query) ||
|
||||
u.matricula.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
function toggleUserSelection(userId: string) {
|
||||
if (selectedUsers.includes(userId)) {
|
||||
selectedUsers = selectedUsers.filter((id) => id !== userId);
|
||||
} else {
|
||||
selectedUsers = [...selectedUsers, userId];
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
if (selectedUsers.length < 2) {
|
||||
alert("Selecione pelo menos 2 participantes");
|
||||
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) {
|
||||
console.error("Erro ao criar grupo:", error);
|
||||
alert("Erro ao criar grupo");
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="fixed inset-0 z-[100] flex items-center justify-center bg-black/50" onclick={onClose}>
|
||||
<div
|
||||
class="bg-base-100 rounded-xl shadow-2xl w-full max-w-lg max-h-[80vh] flex flex-col m-4"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-base-300">
|
||||
<h2 class="text-xl font-semibold">Nova Conversa</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm btn-circle"
|
||||
onclick={onClose}
|
||||
aria-label="Fechar"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="tabs tabs-boxed p-4">
|
||||
<button
|
||||
type="button"
|
||||
class={`tab ${activeTab === "individual" ? "tab-active" : ""}`}
|
||||
onclick={() => (activeTab = "individual")}
|
||||
>
|
||||
Individual
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={`tab ${activeTab === "grupo" ? "tab-active" : ""}`}
|
||||
onclick={() => (activeTab = "grupo")}
|
||||
>
|
||||
Grupo
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 overflow-y-auto px-6">
|
||||
{#if activeTab === "grupo"}
|
||||
<!-- Criar Grupo -->
|
||||
<div class="mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text">Nome do Grupo</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Digite o nome do grupo..."
|
||||
class="input input-bordered w-full"
|
||||
bind:value={groupName}
|
||||
maxlength="50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<label class="label">
|
||||
<span class="label-text">
|
||||
Participantes {selectedUsers.length > 0 ? `(${selectedUsers.length})` : ""}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Search -->
|
||||
<div class="mb-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar usuários..."
|
||||
class="input input-bordered w-full"
|
||||
bind:value={searchQuery}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Lista de usuários -->
|
||||
<div class="space-y-2">
|
||||
{#if usuarios && usuariosFiltrados().length > 0}
|
||||
{#each usuariosFiltrados() as usuario (usuario._id)}
|
||||
<button
|
||||
type="button"
|
||||
class={`w-full text-left px-4 py-3 rounded-lg border transition-colors flex items-center gap-3 ${
|
||||
activeTab === "grupo" && selectedUsers.includes(usuario._id)
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-base-300 hover:bg-base-200"
|
||||
}`}
|
||||
onclick={() => {
|
||||
if (activeTab === "individual") {
|
||||
handleCriarIndividual(usuario._id);
|
||||
} else {
|
||||
toggleUserSelection(usuario._id);
|
||||
}
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
<!-- Avatar -->
|
||||
<div class="relative flex-shrink-0">
|
||||
<UserAvatar
|
||||
avatar={usuario.avatar}
|
||||
fotoPerfilUrl={usuario.fotoPerfil}
|
||||
nome={usuario.nome}
|
||||
size="sm"
|
||||
/>
|
||||
<div class="absolute bottom-0 right-0">
|
||||
<UserStatusBadge status={usuario.statusPresenca} size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-medium text-base-content truncate">{usuario.nome}</p>
|
||||
<p class="text-sm text-base-content/60 truncate">
|
||||
{usuario.setor || usuario.email}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Checkbox (apenas para grupo) -->
|
||||
{#if activeTab === "grupo"}
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-primary"
|
||||
checked={selectedUsers.includes(usuario._id)}
|
||||
readonly
|
||||
/>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{:else if !usuarios}
|
||||
<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">
|
||||
Nenhum usuário encontrado
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer (apenas para grupo) -->
|
||||
{#if activeTab === "grupo"}
|
||||
<div class="px-6 py-4 border-t border-base-300">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-block"
|
||||
onclick={handleCriarGrupo}
|
||||
disabled={loading || selectedUsers.length < 2 || !groupName.trim()}
|
||||
>
|
||||
{#if loading}
|
||||
<span class="loading loading-spinner"></span>
|
||||
Criando...
|
||||
{:else}
|
||||
Criar Grupo
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
220
apps/web/src/lib/components/chat/NotificationBell.svelte
Normal file
220
apps/web/src/lib/components/chat/NotificationBell.svelte
Normal file
@@ -0,0 +1,220 @@
|
||||
<script lang="ts">
|
||||
import { useQuery, useConvexClient } from "convex-svelte";
|
||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||
import { notificacoesCount } from "$lib/stores/chatStore";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { ptBR } from "date-fns/locale";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
// Queries e Client
|
||||
const client = useConvexClient();
|
||||
const notificacoes = useQuery(api.chat.obterNotificacoes, { apenasPendentes: true });
|
||||
const count = useQuery(api.chat.contarNotificacoesNaoLidas, {});
|
||||
|
||||
let dropdownOpen = $state(false);
|
||||
|
||||
// Atualizar contador no store
|
||||
$effect(() => {
|
||||
if (count !== undefined) {
|
||||
notificacoesCount.set(count);
|
||||
}
|
||||
});
|
||||
|
||||
function formatarTempo(timestamp: number): string {
|
||||
try {
|
||||
return formatDistanceToNow(new Date(timestamp), {
|
||||
addSuffix: true,
|
||||
locale: ptBR,
|
||||
});
|
||||
} catch {
|
||||
return "agora";
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMarcarTodasLidas() {
|
||||
await client.mutation(api.chat.marcarTodasNotificacoesLidas, {});
|
||||
dropdownOpen = false;
|
||||
}
|
||||
|
||||
async function handleClickNotificacao(notificacaoId: string) {
|
||||
await client.mutation(api.chat.marcarNotificacaoLida, { notificacaoId: notificacaoId as any });
|
||||
dropdownOpen = false;
|
||||
}
|
||||
|
||||
function toggleDropdown() {
|
||||
dropdownOpen = !dropdownOpen;
|
||||
}
|
||||
|
||||
// Fechar dropdown ao clicar fora
|
||||
onMount(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
if (!target.closest(".notification-bell")) {
|
||||
dropdownOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("click", handleClickOutside);
|
||||
return () => document.removeEventListener("click", handleClickOutside);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="dropdown dropdown-end notification-bell">
|
||||
<button
|
||||
type="button"
|
||||
tabindex="0"
|
||||
class="btn btn-ghost btn-circle relative"
|
||||
onclick={toggleDropdown}
|
||||
aria-label="Notificações"
|
||||
>
|
||||
<!-- Ícone do sino -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="w-6 h-6"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<!-- Badge de contador -->
|
||||
{#if count && count > 0}
|
||||
<span
|
||||
class="absolute top-1 right-1 flex h-5 w-5 items-center justify-center rounded-full bg-error text-error-content text-xs font-bold"
|
||||
>
|
||||
{count > 9 ? "9+" : count}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if dropdownOpen}
|
||||
<div
|
||||
tabindex="0"
|
||||
class="dropdown-content z-50 mt-3 w-80 max-h-96 overflow-auto rounded-box bg-base-100 p-2 shadow-2xl border border-base-300"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-4 py-2 border-b border-base-300">
|
||||
<h3 class="text-lg font-semibold">Notificações</h3>
|
||||
{#if count && count > 0}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs"
|
||||
onclick={handleMarcarTodasLidas}
|
||||
>
|
||||
Marcar todas como lidas
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Lista de notificações -->
|
||||
<div class="py-2">
|
||||
{#if notificacoes && notificacoes.length > 0}
|
||||
{#each notificacoes.slice(0, 10) as notificacao (notificacao._id)}
|
||||
<button
|
||||
type="button"
|
||||
class="w-full text-left px-4 py-3 hover:bg-base-200 rounded-lg transition-colors"
|
||||
onclick={() => handleClickNotificacao(notificacao._id)}
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<!-- Ícone -->
|
||||
<div class="flex-shrink-0 mt-1">
|
||||
{#if notificacao.tipo === "nova_mensagem"}
|
||||
<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 text-primary"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75"
|
||||
/>
|
||||
</svg>
|
||||
{:else if notificacao.tipo === "mencao"}
|
||||
<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 text-warning"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M16.5 12a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0Zm0 0c0 1.657 1.007 3 2.25 3S21 13.657 21 12a9 9 0 1 0-2.636 6.364M16.5 12V8.25"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<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 text-info"
|
||||
>
|
||||
<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">
|
||||
<p class="text-sm font-medium text-base-content">
|
||||
{notificacao.titulo}
|
||||
</p>
|
||||
<p class="text-xs text-base-content/70 truncate">
|
||||
{notificacao.descricao}
|
||||
</p>
|
||||
<p class="text-xs text-base-content/50 mt-1">
|
||||
{formatarTempo(notificacao.criadaEm)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Indicador de não lida -->
|
||||
{#if !notificacao.lida}
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-2 h-2 rounded-full bg-primary"></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="px-4 py-8 text-center text-base-content/50">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="w-12 h-12 mx-auto mb-2 opacity-50"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9.143 17.082a24.248 24.248 0 0 0 3.844.148m-3.844-.148a23.856 23.856 0 0 1-5.455-1.31 8.964 8.964 0 0 0 2.3-5.542m3.155 6.852a3 3 0 0 0 5.667 1.97m1.965-2.277L21 21m-4.225-4.225a23.81 23.81 0 0 0 3.536-1.003A8.967 8.967 0 0 1 18 9.75V9A6 6 0 0 0 6.53 6.53m10.245 10.245L6.53 6.53M3 3l3.53 3.53"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-sm">Nenhuma notificação</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
87
apps/web/src/lib/components/chat/PresenceManager.svelte
Normal file
87
apps/web/src/lib/components/chat/PresenceManager.svelte
Normal file
@@ -0,0 +1,87 @@
|
||||
<script lang="ts">
|
||||
import { useConvexClient } from "convex-svelte";
|
||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
const client = useConvexClient();
|
||||
|
||||
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let inactivityTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let lastActivity = Date.now();
|
||||
|
||||
// Detectar atividade do usuário
|
||||
function handleActivity() {
|
||||
lastActivity = Date.now();
|
||||
|
||||
// Limpar timeout de inatividade anterior
|
||||
if (inactivityTimeout) {
|
||||
clearTimeout(inactivityTimeout);
|
||||
}
|
||||
|
||||
// Configurar novo timeout (5 minutos)
|
||||
inactivityTimeout = setTimeout(() => {
|
||||
client.mutation(api.chat.atualizarStatusPresenca, { status: "ausente" });
|
||||
}, 5 * 60 * 1000);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Configurar como online ao montar
|
||||
client.mutation(api.chat.atualizarStatusPresenca, { status: "online" });
|
||||
|
||||
// Heartbeat a cada 30 segundos
|
||||
heartbeatInterval = setInterval(() => {
|
||||
const timeSinceLastActivity = Date.now() - lastActivity;
|
||||
|
||||
// Se houve atividade nos últimos 5 minutos, manter online
|
||||
if (timeSinceLastActivity < 5 * 60 * 1000) {
|
||||
client.mutation(api.chat.atualizarStatusPresenca, { status: "online" });
|
||||
}
|
||||
}, 30 * 1000);
|
||||
|
||||
// Listeners para detectar atividade
|
||||
const events = ["mousedown", "keydown", "scroll", "touchstart"];
|
||||
events.forEach((event) => {
|
||||
window.addEventListener(event, handleActivity);
|
||||
});
|
||||
|
||||
// Configurar timeout inicial de inatividade
|
||||
handleActivity();
|
||||
|
||||
// Detectar quando a aba fica inativa/ativa
|
||||
function handleVisibilityChange() {
|
||||
if (document.hidden) {
|
||||
// Aba ficou inativa
|
||||
client.mutation(api.chat.atualizarStatusPresenca, { status: "ausente" });
|
||||
} else {
|
||||
// Aba ficou ativa
|
||||
client.mutation(api.chat.atualizarStatusPresenca, { status: "online" });
|
||||
handleActivity();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
// Marcar como offline ao desmontar
|
||||
client.mutation(api.chat.atualizarStatusPresenca, { status: "offline" });
|
||||
|
||||
if (heartbeatInterval) {
|
||||
clearInterval(heartbeatInterval);
|
||||
}
|
||||
|
||||
if (inactivityTimeout) {
|
||||
clearTimeout(inactivityTimeout);
|
||||
}
|
||||
|
||||
events.forEach((event) => {
|
||||
window.removeEventListener(event, handleActivity);
|
||||
});
|
||||
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Componente invisível - apenas lógica -->
|
||||
|
||||
307
apps/web/src/lib/components/chat/ScheduleMessageModal.svelte
Normal file
307
apps/web/src/lib/components/chat/ScheduleMessageModal.svelte
Normal file
@@ -0,0 +1,307 @@
|
||||
<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";
|
||||
|
||||
interface Props {
|
||||
conversaId: Id<"conversas">;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { conversaId, onClose }: Props = $props();
|
||||
|
||||
const client = useConvexClient();
|
||||
const mensagensAgendadas = useQuery(api.chat.obterMensagensAgendadas, { conversaId });
|
||||
|
||||
let mensagem = $state("");
|
||||
let data = $state("");
|
||||
let hora = $state("");
|
||||
let loading = $state(false);
|
||||
|
||||
// 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 "";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
await client.mutation(api.chat.agendarMensagem, {
|
||||
conversaId,
|
||||
conteudo: mensagem.trim(),
|
||||
agendadaPara: dataHora.getTime(),
|
||||
});
|
||||
|
||||
mensagem = "";
|
||||
data = "";
|
||||
hora = "";
|
||||
alert("Mensagem agendada com sucesso!");
|
||||
} 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;
|
||||
|
||||
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";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="fixed inset-0 z-[100] flex items-center justify-center bg-black/50" onclick={onClose}>
|
||||
<div
|
||||
class="bg-base-100 rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] flex flex-col m-4"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-base-300">
|
||||
<h2 class="text-xl font-semibold">Agendar Mensagem</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm btn-circle"
|
||||
onclick={onClose}
|
||||
aria-label="Fechar"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
<span class="label-text">Mensagem</span>
|
||||
</label>
|
||||
<textarea
|
||||
class="textarea textarea-bordered h-24"
|
||||
placeholder="Digite a mensagem..."
|
||||
bind:value={mensagem}
|
||||
maxlength="500"
|
||||
></textarea>
|
||||
<label class="label">
|
||||
<span class="label-text-alt">{mensagem.length}/500</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Data</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
class="input input-bordered"
|
||||
bind:value={data}
|
||||
min={minDate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Hora</span>
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
class="input input-bordered"
|
||||
bind:value={hora}
|
||||
min={data === minDate ? minTime : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if getPreviewText()}
|
||||
<div class="alert alert-info">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="w-6 h-6"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{getPreviewText()}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="card-actions justify-end">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
onclick={handleAgendar}
|
||||
disabled={loading || !mensagem.trim() || !data || !hora}
|
||||
>
|
||||
{#if loading}
|
||||
<span class="loading loading-spinner"></span>
|
||||
Agendando...
|
||||
{:else}
|
||||
<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"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
|
||||
/>
|
||||
</svg>
|
||||
Agendar
|
||||
{/if}
|
||||
</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>
|
||||
|
||||
{#if mensagensAgendadas && mensagensAgendadas.length > 0}
|
||||
<div class="space-y-3">
|
||||
{#each mensagensAgendadas as msg (msg._id)}
|
||||
<div class="flex items-start gap-3 p-3 bg-base-100 rounded-lg">
|
||||
<div class="flex-shrink-0 mt-1">
|
||||
<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 text-primary"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
|
||||
/>
|
||||
</svg>
|
||||
</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>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm btn-circle text-error"
|
||||
onclick={() => handleCancelar(msg._id)}
|
||||
aria-label="Cancelar"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if !mensagensAgendadas}
|
||||
<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">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="w-12 h-12 mx-auto mb-2 opacity-50"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-sm">Nenhuma mensagem agendada</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
41
apps/web/src/lib/components/chat/UserAvatar.svelte
Normal file
41
apps/web/src/lib/components/chat/UserAvatar.svelte
Normal file
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import { getAvatarUrl as generateAvatarUrl } from "$lib/utils/avatarGenerator";
|
||||
|
||||
interface Props {
|
||||
avatar?: string;
|
||||
fotoPerfilUrl?: string | null;
|
||||
nome: string;
|
||||
size?: "xs" | "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
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",
|
||||
};
|
||||
|
||||
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
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="avatar">
|
||||
<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>
|
||||
|
||||
46
apps/web/src/lib/components/chat/UserStatusBadge.svelte
Normal file
46
apps/web/src/lib/components/chat/UserStatusBadge.svelte
Normal file
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
status?: "online" | "offline" | "ausente" | "externo" | "em_reuniao";
|
||||
size?: "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
let { status = "offline", size = "md" }: Props = $props();
|
||||
|
||||
const sizeClasses = {
|
||||
sm: "w-2 h-2",
|
||||
md: "w-3 h-3",
|
||||
lg: "w-4 h-4",
|
||||
};
|
||||
|
||||
const statusConfig = {
|
||||
online: {
|
||||
color: "bg-success",
|
||||
label: "Online",
|
||||
},
|
||||
offline: {
|
||||
color: "bg-base-300",
|
||||
label: "Offline",
|
||||
},
|
||||
ausente: {
|
||||
color: "bg-warning",
|
||||
label: "Ausente",
|
||||
},
|
||||
externo: {
|
||||
color: "bg-info",
|
||||
label: "Externo",
|
||||
},
|
||||
em_reuniao: {
|
||||
color: "bg-error",
|
||||
label: "Em Reunião",
|
||||
},
|
||||
};
|
||||
|
||||
const config = $derived(statusConfig[status]);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={`${sizeClasses[size]} ${config.color} rounded-full`}
|
||||
title={config.label}
|
||||
aria-label={config.label}
|
||||
></div>
|
||||
|
||||
Reference in New Issue
Block a user