refactor: integrate current user data across components
- Replaced instances of `authStore` with `currentUser` to streamline user authentication handling. - Updated permission checks and user-related data retrieval to utilize the new `useQuery` for better performance and clarity. - Cleaned up component structures and improved formatting for consistency and readability. - Enhanced error handling and user feedback mechanisms in various components to improve user experience.
This commit is contained in:
@@ -291,7 +291,7 @@
|
||||
>
|
||||
<!-- Ícone de mensagem -->
|
||||
<div
|
||||
class="flex-shrink-0 w-10 h-10 rounded-xl flex items-center justify-center transition-all duration-300 hover:scale-110"
|
||||
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
|
||||
@@ -312,7 +312,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Avatar -->
|
||||
<div class="relative flex-shrink-0">
|
||||
<div class="relative shrink-0">
|
||||
<UserAvatar
|
||||
avatar={usuario.avatar}
|
||||
fotoPerfilUrl={usuario.fotoPerfilUrl}
|
||||
@@ -396,7 +396,7 @@
|
||||
>
|
||||
<!-- Ícone de grupo/sala -->
|
||||
<div
|
||||
class="flex-shrink-0 w-10 h-10 rounded-xl flex items-center justify-center transition-all duration-300 hover:scale-110 {conversa.tipo ===
|
||||
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'}"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,38 +10,51 @@
|
||||
import ScheduleMessageModal from "./ScheduleMessageModal.svelte";
|
||||
import SalaReuniaoManager from "./SalaReuniaoManager.svelte";
|
||||
import { getAvatarUrl } from "$lib/utils/avatarGenerator";
|
||||
import { authStore } from "$lib/stores/auth.svelte";
|
||||
import { setupConvexAuth } from "$lib/hooks/convexAuth";
|
||||
import { Bell, X, ArrowLeft, LogOut, MoreVertical, Users, Clock, XCircle } from "lucide-svelte";
|
||||
import {
|
||||
Bell,
|
||||
X,
|
||||
ArrowLeft,
|
||||
LogOut,
|
||||
MoreVertical,
|
||||
Users,
|
||||
Clock,
|
||||
XCircle,
|
||||
} from "lucide-svelte";
|
||||
|
||||
interface Props {
|
||||
conversaId: string;
|
||||
}
|
||||
|
||||
let { conversaId }: Props = $props();
|
||||
|
||||
|
||||
const client = useConvexClient();
|
||||
|
||||
|
||||
// Token é passado automaticamente via interceptadores em +layout.svelte
|
||||
|
||||
|
||||
let showScheduleModal = $state(false);
|
||||
let showSalaManager = $state(false);
|
||||
let showAdminMenu = $state(false);
|
||||
let showNotificacaoModal = $state(false);
|
||||
|
||||
const conversas = useQuery(api.chat.listarConversas, {});
|
||||
const isAdmin = useQuery(api.chat.verificarSeEhAdmin, { conversaId: conversaId as Id<"conversas"> });
|
||||
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);
|
||||
|
||||
|
||||
if (!conversas?.data || !Array.isArray(conversas.data)) {
|
||||
console.log("⚠️ [ChatWindow] conversas.data não é um array ou está vazio");
|
||||
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);
|
||||
|
||||
const encontrada = conversas.data.find(
|
||||
(c: { _id: string }) => c._id === conversaId,
|
||||
);
|
||||
console.log("✅ [ChatWindow] Conversa encontrada:", encontrada);
|
||||
return encontrada;
|
||||
});
|
||||
@@ -50,7 +63,10 @@
|
||||
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.nome ||
|
||||
(c.tipo === "sala_reuniao" ? "Sala sem nome" : "Grupo sem nome")
|
||||
);
|
||||
}
|
||||
return c.outroUsuario?.nome || "Usuário";
|
||||
}
|
||||
@@ -67,10 +83,23 @@
|
||||
return "👤";
|
||||
}
|
||||
|
||||
function getStatusConversa(): "online" | "offline" | "ausente" | "externo" | "em_reuniao" | 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 (
|
||||
(c.outroUsuario.statusPresenca as
|
||||
| "online"
|
||||
| "offline"
|
||||
| "ausente"
|
||||
| "externo"
|
||||
| "em_reuniao") || "offline"
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -86,9 +115,13 @@
|
||||
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"}"?`)) {
|
||||
if (
|
||||
!confirm(
|
||||
`Tem certeza que deseja sair da ${tipoTexto} "${c.nome || "Sem nome"}"?`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -104,7 +137,8 @@
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Erro ao sair da conversa:", error);
|
||||
const errorMessage = error instanceof Error ? error.message : "Erro ao sair da conversa";
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Erro ao sair da conversa";
|
||||
alert(errorMessage);
|
||||
}
|
||||
}
|
||||
@@ -112,7 +146,10 @@
|
||||
|
||||
<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()}>
|
||||
<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"
|
||||
@@ -121,14 +158,11 @@
|
||||
aria-label="Voltar"
|
||||
title="Voltar para lista de conversas"
|
||||
>
|
||||
<ArrowLeft
|
||||
class="w-6 h-6 text-primary"
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
<ArrowLeft class="w-6 h-6 text-primary" strokeWidth={2.5} />
|
||||
</button>
|
||||
|
||||
<!-- Avatar e Info -->
|
||||
<div class="relative flex-shrink-0">
|
||||
<div class="relative shrink-0">
|
||||
{#if conversa() && conversa()?.tipo === "individual" && conversa()?.outroUsuario}
|
||||
<UserAvatar
|
||||
avatar={conversa()?.outroUsuario?.avatar}
|
||||
@@ -151,9 +185,13 @@
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-semibold text-base-content truncate">{getNomeConversa()}</p>
|
||||
<p class="font-semibold text-base-content truncate">
|
||||
{getNomeConversa()}
|
||||
</p>
|
||||
{#if getStatusMensagem()}
|
||||
<p class="text-xs text-base-content/60 truncate">{getStatusMensagem()}</p>
|
||||
<p class="text-xs text-base-content/60 truncate">
|
||||
{getStatusMensagem()}
|
||||
</p>
|
||||
{:else if getStatusConversa()}
|
||||
<p class="text-xs text-base-content/60">
|
||||
{getStatusConversa() === "online"
|
||||
@@ -169,30 +207,54 @@
|
||||
{: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"}
|
||||
{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}>
|
||||
<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" />
|
||||
<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" />
|
||||
<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" />
|
||||
<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`}>
|
||||
<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>
|
||||
<span
|
||||
class="text-[10px] text-primary font-semibold ml-1 whitespace-nowrap"
|
||||
title="Você é administrador desta sala">• Admin</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -215,7 +277,9 @@
|
||||
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>
|
||||
<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}
|
||||
@@ -237,7 +301,9 @@
|
||||
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>
|
||||
<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}
|
||||
@@ -283,11 +349,19 @@
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
(async () => {
|
||||
if (!confirm("Tem certeza que deseja encerrar esta reunião? Todos os participantes serão removidos.")) return;
|
||||
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">,
|
||||
});
|
||||
const resultado = await client.mutation(
|
||||
api.chat.encerrarReuniao,
|
||||
{
|
||||
conversaId: conversaId as Id<"conversas">,
|
||||
},
|
||||
);
|
||||
if (resultado.sucesso) {
|
||||
alert("Reunião encerrada com sucesso!");
|
||||
voltarParaLista();
|
||||
@@ -295,7 +369,10 @@
|
||||
alert(resultado.erro || "Erro ao encerrar reunião");
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Erro ao encerrar reunião";
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Erro ao encerrar reunião";
|
||||
alert(errorMessage);
|
||||
}
|
||||
showAdminMenu = false;
|
||||
@@ -310,7 +387,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
<!-- Botão Agendar MODERNO -->
|
||||
<button
|
||||
type="button"
|
||||
@@ -320,7 +397,9 @@
|
||||
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>
|
||||
<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}
|
||||
@@ -335,7 +414,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Input -->
|
||||
<div class="border-t border-base-300 flex-shrink-0">
|
||||
<div class="border-t border-base-300 shrink-0">
|
||||
<MessageInput conversaId={conversaId as Id<"conversas">} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -359,9 +438,15 @@
|
||||
|
||||
<!-- 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)}>
|
||||
<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">
|
||||
<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
|
||||
@@ -381,18 +466,21 @@
|
||||
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 Id<"conversas">,
|
||||
titulo: titulo.trim(),
|
||||
mensagem: mensagem.trim(),
|
||||
});
|
||||
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!");
|
||||
@@ -401,7 +489,10 @@
|
||||
alert(resultado.erro || "Erro ao enviar notificação");
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Erro ao enviar notificação";
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Erro ao enviar notificação";
|
||||
alert(errorMessage);
|
||||
}
|
||||
}}
|
||||
@@ -431,7 +522,11 @@
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button type="button" class="btn btn-ghost flex-1" onclick={() => (showNotificacaoModal = false)}>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost flex-1"
|
||||
onclick={() => (showNotificacaoModal = false)}
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary flex-1">
|
||||
@@ -442,8 +537,9 @@
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button type="button" onclick={() => (showNotificacaoModal = false)}>fechar</button>
|
||||
<button type="button" onclick={() => (showNotificacaoModal = false)}
|
||||
>fechar</button
|
||||
>
|
||||
</form>
|
||||
</dialog>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||
import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
|
||||
import { onMount } from "svelte";
|
||||
import { authStore } from "$lib/stores/auth.svelte";
|
||||
import { Paperclip, Smile, Send } from "lucide-svelte";
|
||||
|
||||
interface Props {
|
||||
@@ -35,18 +34,67 @@
|
||||
let uploadingFile = $state(false);
|
||||
let digitacaoTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let showEmojiPicker = $state(false);
|
||||
let mensagemRespondendo: { id: Id<"mensagens">; conteudo: string; remetente: string } | null = $state(null);
|
||||
let mensagemRespondendo: {
|
||||
id: Id<"mensagens">;
|
||||
conteudo: string;
|
||||
remetente: string;
|
||||
} | null = $state(null);
|
||||
let showMentionsDropdown = $state(false);
|
||||
let mentionQuery = $state("");
|
||||
let mentionStartPos = $state(0);
|
||||
|
||||
// Emojis mais usados
|
||||
const emojis = [
|
||||
"😀", "😃", "😄", "😁", "😅", "😂", "🤣", "😊", "😇", "🙂",
|
||||
"🙃", "😉", "😌", "😍", "🥰", "😘", "😗", "😙", "😚", "😋",
|
||||
"😛", "😝", "😜", "🤪", "🤨", "🧐", "🤓", "😎", "🥳", "😏",
|
||||
"👍", "👎", "👏", "🙌", "🤝", "🙏", "💪", "✨", "🎉", "🎊",
|
||||
"❤️", "💙", "💚", "💛", "🧡", "💜", "🖤", "🤍", "💯", "🔥",
|
||||
"😀",
|
||||
"😃",
|
||||
"😄",
|
||||
"😁",
|
||||
"😅",
|
||||
"😂",
|
||||
"🤣",
|
||||
"😊",
|
||||
"😇",
|
||||
"🙂",
|
||||
"🙃",
|
||||
"😉",
|
||||
"😌",
|
||||
"😍",
|
||||
"🥰",
|
||||
"😘",
|
||||
"😗",
|
||||
"😙",
|
||||
"😚",
|
||||
"😋",
|
||||
"😛",
|
||||
"😝",
|
||||
"😜",
|
||||
"🤪",
|
||||
"🤨",
|
||||
"🧐",
|
||||
"🤓",
|
||||
"😎",
|
||||
"🥳",
|
||||
"😏",
|
||||
"👍",
|
||||
"👎",
|
||||
"👏",
|
||||
"🙌",
|
||||
"🤝",
|
||||
"🙏",
|
||||
"💪",
|
||||
"✨",
|
||||
"🎉",
|
||||
"🎊",
|
||||
"❤️",
|
||||
"💙",
|
||||
"💚",
|
||||
"💛",
|
||||
"🧡",
|
||||
"💜",
|
||||
"🖤",
|
||||
"🤍",
|
||||
"💯",
|
||||
"🔥",
|
||||
];
|
||||
|
||||
function adicionarEmoji(emoji: string) {
|
||||
@@ -60,7 +108,11 @@
|
||||
// Obter conversa atual
|
||||
const conversa = $derived((): ConversaComParticipantes | null => {
|
||||
if (!conversas?.data) return null;
|
||||
return (conversas.data as ConversaComParticipantes[]).find((c) => c._id === conversaId) || null;
|
||||
return (
|
||||
(conversas.data as ConversaComParticipantes[]).find(
|
||||
(c) => c._id === conversaId,
|
||||
) || null
|
||||
);
|
||||
});
|
||||
|
||||
// Obter participantes para menções (apenas grupos e salas)
|
||||
@@ -74,10 +126,13 @@
|
||||
const participantesFiltrados = $derived((): ParticipanteInfo[] => {
|
||||
if (!mentionQuery.trim()) return participantesParaMencoes().slice(0, 5);
|
||||
const query = mentionQuery.toLowerCase();
|
||||
return participantesParaMencoes().filter((p) =>
|
||||
p.nome?.toLowerCase().includes(query) ||
|
||||
(p.email && p.email.toLowerCase().includes(query))
|
||||
).slice(0, 5);
|
||||
return participantesParaMencoes()
|
||||
.filter(
|
||||
(p) =>
|
||||
p.nome?.toLowerCase().includes(query) ||
|
||||
(p.email && p.email.toLowerCase().includes(query)),
|
||||
)
|
||||
.slice(0, 5);
|
||||
});
|
||||
|
||||
// Auto-resize do textarea e detectar menções
|
||||
@@ -91,19 +146,19 @@
|
||||
// Detectar menções (@)
|
||||
const cursorPos = target.selectionStart || 0;
|
||||
const textBeforeCursor = mensagem.substring(0, cursorPos);
|
||||
const lastAtIndex = textBeforeCursor.lastIndexOf('@');
|
||||
|
||||
const lastAtIndex = textBeforeCursor.lastIndexOf("@");
|
||||
|
||||
if (lastAtIndex !== -1) {
|
||||
const textAfterAt = textBeforeCursor.substring(lastAtIndex + 1);
|
||||
// Se não há espaço após o @, mostrar dropdown
|
||||
if (!textAfterAt.includes(' ') && !textAfterAt.includes('\n')) {
|
||||
if (!textAfterAt.includes(" ") && !textAfterAt.includes("\n")) {
|
||||
mentionQuery = textAfterAt;
|
||||
mentionStartPos = lastAtIndex;
|
||||
showMentionsDropdown = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
showMentionsDropdown = false;
|
||||
|
||||
// Indicador de digitação (debounce de 1s)
|
||||
@@ -118,9 +173,11 @@
|
||||
}
|
||||
|
||||
function inserirMencao(participante: ParticipanteInfo) {
|
||||
const nome = participante.nome.split(' ')[0]; // Usar primeiro nome
|
||||
const nome = participante.nome.split(" ")[0]; // Usar primeiro nome
|
||||
const antes = mensagem.substring(0, mentionStartPos);
|
||||
const depois = mensagem.substring(textarea.selectionStart || mensagem.length);
|
||||
const depois = mensagem.substring(
|
||||
textarea.selectionStart || mensagem.length,
|
||||
);
|
||||
mensagem = antes + `@${nome} ` + depois;
|
||||
showMentionsDropdown = false;
|
||||
mentionQuery = "";
|
||||
@@ -143,8 +200,9 @@
|
||||
let match;
|
||||
while ((match = mentionRegex.exec(texto)) !== null) {
|
||||
const nomeMencionado = match[1];
|
||||
const participante = participantesParaMencoes().find((p) =>
|
||||
p.nome.split(' ')[0].toLowerCase() === nomeMencionado.toLowerCase()
|
||||
const participante = participantesParaMencoes().find(
|
||||
(p) =>
|
||||
p.nome.split(" ")[0].toLowerCase() === nomeMencionado.toLowerCase(),
|
||||
);
|
||||
if (participante) {
|
||||
mencoesIds.push(participante._id);
|
||||
@@ -168,9 +226,12 @@
|
||||
respostaPara: mensagemRespondendo?.id,
|
||||
mencoes: mencoesIds.length > 0 ? mencoesIds : undefined,
|
||||
});
|
||||
|
||||
console.log("✅ [MessageInput] Mensagem enviada com sucesso! ID:", result);
|
||||
|
||||
|
||||
console.log(
|
||||
"✅ [MessageInput] Mensagem enviada com sucesso! ID:",
|
||||
result,
|
||||
);
|
||||
|
||||
mensagem = "";
|
||||
mensagemRespondendo = null;
|
||||
showMentionsDropdown = false;
|
||||
@@ -201,17 +262,21 @@
|
||||
const handler = (e: Event) => {
|
||||
const customEvent = e as CustomEvent<{ mensagemId: Id<"mensagens"> }>;
|
||||
// Buscar informações da mensagem para exibir preview
|
||||
client.query(api.chat.obterMensagens, { conversaId, limit: 100 }).then((mensagens) => {
|
||||
const msg = (mensagens as MensagemComRemetente[]).find((m) => m._id === customEvent.detail.mensagemId);
|
||||
if (msg) {
|
||||
mensagemRespondendo = {
|
||||
id: msg._id,
|
||||
conteudo: msg.conteudo.substring(0, 100),
|
||||
remetente: msg.remetente?.nome || "Usuário",
|
||||
};
|
||||
textarea?.focus();
|
||||
}
|
||||
});
|
||||
client
|
||||
.query(api.chat.obterMensagens, { conversaId, limit: 100 })
|
||||
.then((mensagens) => {
|
||||
const msg = (mensagens as MensagemComRemetente[]).find(
|
||||
(m) => m._id === customEvent.detail.mensagemId,
|
||||
);
|
||||
if (msg) {
|
||||
mensagemRespondendo = {
|
||||
id: msg._id,
|
||||
conteudo: msg.conteudo.substring(0, 100),
|
||||
remetente: msg.remetente?.nome || "Usuário",
|
||||
};
|
||||
textarea?.focus();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener("responderMensagem", handler);
|
||||
@@ -236,7 +301,7 @@
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Enter sem Shift = enviar
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
@@ -259,7 +324,9 @@
|
||||
uploadingFile = true;
|
||||
|
||||
// 1. Obter upload URL
|
||||
const uploadUrl = await client.mutation(api.chat.uploadArquivoChat, { conversaId });
|
||||
const uploadUrl = await client.mutation(api.chat.uploadArquivoChat, {
|
||||
conversaId,
|
||||
});
|
||||
|
||||
// 2. Upload do arquivo
|
||||
const result = await fetch(uploadUrl, {
|
||||
@@ -275,7 +342,9 @@
|
||||
const { storageId } = await result.json();
|
||||
|
||||
// 3. Enviar mensagem com o arquivo
|
||||
const tipo: "imagem" | "arquivo" = file.type.startsWith("image/") ? "imagem" : "arquivo";
|
||||
const tipo: "imagem" | "arquivo" = file.type.startsWith("image/")
|
||||
? "imagem"
|
||||
: "arquivo";
|
||||
await client.mutation(api.chat.enviarMensagem, {
|
||||
conversaId,
|
||||
conteudo: tipo === "imagem" ? "" : file.name,
|
||||
@@ -306,10 +375,16 @@
|
||||
<div class="p-4">
|
||||
<!-- Preview da mensagem respondendo -->
|
||||
{#if mensagemRespondendo}
|
||||
<div class="mb-2 p-2 bg-base-200 rounded-lg flex items-center justify-between">
|
||||
<div
|
||||
class="mb-2 p-2 bg-base-200 rounded-lg flex items-center justify-between"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<p class="text-xs font-medium text-base-content/70">Respondendo a {mensagemRespondendo.remetente}</p>
|
||||
<p class="text-xs text-base-content/50 truncate">{mensagemRespondendo.conteudo}</p>
|
||||
<p class="text-xs font-medium text-base-content/70">
|
||||
Respondendo a {mensagemRespondendo.remetente}
|
||||
</p>
|
||||
<p class="text-xs text-base-content/50 truncate">
|
||||
{mensagemRespondendo.conteudo}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -324,8 +399,8 @@
|
||||
|
||||
<div class="flex items-end gap-2">
|
||||
<!-- Botão de anexar arquivo MODERNO -->
|
||||
<label
|
||||
class="flex items-center justify-center w-10 h-10 rounded-xl transition-all duration-300 group relative overflow-hidden cursor-pointer flex-shrink-0"
|
||||
<label
|
||||
class="flex items-center justify-center w-10 h-10 rounded-xl transition-all duration-300 group relative overflow-hidden cursor-pointer shrink-0"
|
||||
style="background: rgba(102, 126, 234, 0.1); border: 1px solid rgba(102, 126, 234, 0.2);"
|
||||
title="Anexar arquivo"
|
||||
>
|
||||
@@ -336,7 +411,9 @@
|
||||
disabled={uploadingFile || enviando}
|
||||
accept="*/*"
|
||||
/>
|
||||
<div class="absolute inset-0 bg-primary/0 group-hover:bg-primary/10 transition-colors duration-300"></div>
|
||||
<div
|
||||
class="absolute inset-0 bg-primary/0 group-hover:bg-primary/10 transition-colors duration-300"
|
||||
></div>
|
||||
{#if uploadingFile}
|
||||
<span class="loading loading-spinner loading-sm relative z-10"></span>
|
||||
{:else}
|
||||
@@ -349,7 +426,7 @@
|
||||
</label>
|
||||
|
||||
<!-- Botão de EMOJI MODERNO -->
|
||||
<div class="relative flex-shrink-0">
|
||||
<div class="relative shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center w-10 h-10 rounded-xl transition-all duration-300 group relative overflow-hidden"
|
||||
@@ -359,7 +436,9 @@
|
||||
aria-label="Adicionar emoji"
|
||||
title="Adicionar emoji"
|
||||
>
|
||||
<div class="absolute inset-0 bg-warning/0 group-hover:bg-warning/10 transition-colors duration-300"></div>
|
||||
<div
|
||||
class="absolute inset-0 bg-warning/0 group-hover:bg-warning/10 transition-colors duration-300"
|
||||
></div>
|
||||
<Smile
|
||||
class="w-5 h-5 text-warning relative z-10 group-hover:scale-110 transition-transform"
|
||||
strokeWidth={2}
|
||||
@@ -368,7 +447,7 @@
|
||||
|
||||
<!-- Picker de Emojis -->
|
||||
{#if showEmojiPicker}
|
||||
<div
|
||||
<div
|
||||
class="absolute bottom-full left-0 mb-2 p-3 bg-base-100 rounded-xl shadow-2xl border border-base-300 z-50"
|
||||
style="width: 280px; max-height: 200px; overflow-y-auto;"
|
||||
>
|
||||
@@ -399,26 +478,38 @@
|
||||
rows="1"
|
||||
disabled={enviando || uploadingFile}
|
||||
></textarea>
|
||||
|
||||
|
||||
<!-- Dropdown de Menções -->
|
||||
{#if showMentionsDropdown && participantesFiltrados().length > 0 && (conversa()?.tipo === "grupo" || conversa()?.tipo === "sala_reuniao")}
|
||||
<div class="absolute bottom-full left-0 mb-2 bg-base-100 rounded-lg shadow-xl border border-base-300 z-50 w-64 max-h-48 overflow-y-auto">
|
||||
<div
|
||||
class="absolute bottom-full left-0 mb-2 bg-base-100 rounded-lg shadow-xl border border-base-300 z-50 w-64 max-h-48 overflow-y-auto"
|
||||
>
|
||||
{#each participantesFiltrados() as participante (participante._id)}
|
||||
<button
|
||||
type="button"
|
||||
class="w-full text-left px-4 py-2 hover:bg-base-200 transition-colors flex items-center gap-2"
|
||||
onclick={() => inserirMencao(participante)}
|
||||
>
|
||||
<div class="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center overflow-hidden">
|
||||
<div
|
||||
class="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center overflow-hidden"
|
||||
>
|
||||
{#if participante.fotoPerfilUrl}
|
||||
<img src={participante.fotoPerfilUrl} alt={participante.nome} class="w-full h-full object-cover" />
|
||||
<img
|
||||
src={participante.fotoPerfilUrl}
|
||||
alt={participante.nome}
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<span class="text-xs font-semibold">{participante.nome.charAt(0).toUpperCase()}</span>
|
||||
<span class="text-xs font-semibold"
|
||||
>{participante.nome.charAt(0).toUpperCase()}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium truncate">{participante.nome}</p>
|
||||
<p class="text-xs text-base-content/60 truncate">@{participante.nome.split(' ')[0]}</p>
|
||||
<p class="text-xs text-base-content/60 truncate">
|
||||
@{participante.nome.split(" ")[0]}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
@@ -429,15 +520,19 @@
|
||||
<!-- Botão de enviar MODERNO -->
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center w-12 h-12 rounded-xl transition-all duration-300 group relative overflow-hidden flex-shrink-0 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
class="flex items-center justify-center w-12 h-12 rounded-xl transition-all duration-300 group relative overflow-hidden shrink-0 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={handleEnviar}
|
||||
disabled={!mensagem.trim() || enviando || uploadingFile}
|
||||
aria-label="Enviar"
|
||||
>
|
||||
<div class="absolute inset-0 bg-white/0 group-hover:bg-white/10 transition-colors duration-300"></div>
|
||||
<div
|
||||
class="absolute inset-0 bg-white/0 group-hover:bg-white/10 transition-colors duration-300"
|
||||
></div>
|
||||
{#if enviando}
|
||||
<span class="loading loading-spinner loading-sm relative z-10 text-white"></span>
|
||||
<span
|
||||
class="loading loading-spinner loading-sm relative z-10 text-white"
|
||||
></span>
|
||||
{:else}
|
||||
<!-- Ícone de avião de papel moderno -->
|
||||
<Send
|
||||
@@ -452,4 +547,3 @@
|
||||
💡 Enter para enviar • Shift+Enter para quebrar linha • 😊 Clique no emoji
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
import { format } from "date-fns";
|
||||
import { ptBR } from "date-fns/locale";
|
||||
import { onMount, tick } from "svelte";
|
||||
import { authStore } from "$lib/stores/auth.svelte";
|
||||
|
||||
interface Props {
|
||||
conversaId: Id<"conversas">;
|
||||
@@ -14,17 +13,25 @@
|
||||
let { conversaId }: Props = $props();
|
||||
|
||||
const client = useConvexClient();
|
||||
const mensagens = useQuery(api.chat.obterMensagens, { conversaId, limit: 50 });
|
||||
const mensagens = useQuery(api.chat.obterMensagens, {
|
||||
conversaId,
|
||||
limit: 50,
|
||||
});
|
||||
const digitando = useQuery(api.chat.obterDigitando, { conversaId });
|
||||
const isAdmin = useQuery(api.chat.verificarSeEhAdmin, { conversaId });
|
||||
const conversas = useQuery(api.chat.listarConversas, {});
|
||||
// Usuário atual
|
||||
const currentUser = useQuery(api.auth.getCurrentUser, {});
|
||||
|
||||
let messagesContainer: HTMLDivElement;
|
||||
let shouldScrollToBottom = true;
|
||||
let lastMessageCount = 0;
|
||||
let mensagensNotificadas = $state<Set<string>>(new Set());
|
||||
let showNotificationPopup = $state(false);
|
||||
let notificationMessage = $state<{ remetente: string; conteudo: string } | null>(null);
|
||||
let notificationMessage = $state<{
|
||||
remetente: string;
|
||||
conteudo: string;
|
||||
} | null>(null);
|
||||
let notificationTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let mensagensCarregadas = $state(false);
|
||||
|
||||
@@ -33,8 +40,8 @@
|
||||
|
||||
// Carregar mensagens já notificadas do localStorage ao montar
|
||||
$effect(() => {
|
||||
if (typeof window !== 'undefined' && !mensagensCarregadas) {
|
||||
const saved = localStorage.getItem('chat-mensagens-notificadas');
|
||||
if (typeof window !== "undefined" && !mensagensCarregadas) {
|
||||
const saved = localStorage.getItem("chat-mensagens-notificadas");
|
||||
if (saved) {
|
||||
try {
|
||||
const ids = JSON.parse(saved) as string[];
|
||||
@@ -44,7 +51,7 @@
|
||||
}
|
||||
}
|
||||
mensagensCarregadas = true;
|
||||
|
||||
|
||||
// Marcar todas as mensagens atuais como já visualizadas (não tocar beep ao abrir)
|
||||
if (mensagens?.data && mensagens.data.length > 0) {
|
||||
mensagens.data.forEach((msg) => {
|
||||
@@ -57,17 +64,20 @@
|
||||
|
||||
// Salvar mensagens notificadas no localStorage
|
||||
function salvarMensagensNotificadas() {
|
||||
if (typeof window !== 'undefined') {
|
||||
if (typeof window !== "undefined") {
|
||||
const ids = Array.from(mensagensNotificadas);
|
||||
// Limitar a 1000 IDs para não encher o localStorage
|
||||
const idsLimitados = ids.slice(-1000);
|
||||
localStorage.setItem('chat-mensagens-notificadas', JSON.stringify(idsLimitados));
|
||||
localStorage.setItem(
|
||||
"chat-mensagens-notificadas",
|
||||
JSON.stringify(idsLimitados),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Atualizar usuarioAtualId sempre que authStore.usuario mudar
|
||||
// Atualizar usuarioAtualId sempre que currentUser mudar
|
||||
$effect(() => {
|
||||
const usuario = authStore.usuario;
|
||||
const usuario = currentUser?.data;
|
||||
if (usuario?._id) {
|
||||
const idStr = String(usuario._id).trim();
|
||||
usuarioAtualId = idStr || null;
|
||||
@@ -80,11 +90,14 @@
|
||||
function tocarSomNotificacao() {
|
||||
try {
|
||||
// Usar AudioContext (requer interação do usuário para iniciar)
|
||||
const AudioContextClass = window.AudioContext || (window as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||
const AudioContextClass =
|
||||
window.AudioContext ||
|
||||
(window as { webkitAudioContext?: typeof AudioContext })
|
||||
.webkitAudioContext;
|
||||
if (!AudioContextClass) return;
|
||||
|
||||
|
||||
let audioContext: AudioContext | null = null;
|
||||
|
||||
|
||||
try {
|
||||
audioContext = new AudioContext();
|
||||
} catch (e) {
|
||||
@@ -92,40 +105,49 @@
|
||||
console.warn("Não foi possível criar AudioContext:", e);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Resumir contexto se estiver suspenso (necessário após interação do usuário)
|
||||
if (audioContext.state === 'suspended') {
|
||||
audioContext.resume().then(() => {
|
||||
const oscillator = audioContext!.createOscillator();
|
||||
const gainNode = audioContext!.createGain();
|
||||
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(audioContext!.destination);
|
||||
|
||||
oscillator.frequency.value = 800;
|
||||
oscillator.type = 'sine';
|
||||
|
||||
gainNode.gain.setValueAtTime(0.2, audioContext!.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext!.currentTime + 0.3);
|
||||
|
||||
oscillator.start(audioContext!.currentTime);
|
||||
oscillator.stop(audioContext!.currentTime + 0.3);
|
||||
}).catch(() => {
|
||||
// Ignorar erro se não conseguir resumir
|
||||
});
|
||||
if (audioContext.state === "suspended") {
|
||||
audioContext
|
||||
.resume()
|
||||
.then(() => {
|
||||
const oscillator = audioContext!.createOscillator();
|
||||
const gainNode = audioContext!.createGain();
|
||||
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(audioContext!.destination);
|
||||
|
||||
oscillator.frequency.value = 800;
|
||||
oscillator.type = "sine";
|
||||
|
||||
gainNode.gain.setValueAtTime(0.2, audioContext!.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(
|
||||
0.01,
|
||||
audioContext!.currentTime + 0.3,
|
||||
);
|
||||
|
||||
oscillator.start(audioContext!.currentTime);
|
||||
oscillator.stop(audioContext!.currentTime + 0.3);
|
||||
})
|
||||
.catch(() => {
|
||||
// Ignorar erro se não conseguir resumir
|
||||
});
|
||||
} else {
|
||||
const oscillator = audioContext.createOscillator();
|
||||
const gainNode = audioContext.createGain();
|
||||
|
||||
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(audioContext.destination);
|
||||
|
||||
|
||||
oscillator.frequency.value = 800;
|
||||
oscillator.type = 'sine';
|
||||
|
||||
oscillator.type = "sine";
|
||||
|
||||
gainNode.gain.setValueAtTime(0.2, audioContext.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.3);
|
||||
|
||||
gainNode.gain.exponentialRampToValueAtTime(
|
||||
0.01,
|
||||
audioContext.currentTime + 0.3,
|
||||
);
|
||||
|
||||
oscillator.start(audioContext.currentTime);
|
||||
oscillator.stop(audioContext.currentTime + 0.3);
|
||||
}
|
||||
@@ -140,31 +162,39 @@
|
||||
if (mensagens?.data && messagesContainer) {
|
||||
const currentCount = mensagens.data.length;
|
||||
const isNewMessage = currentCount > lastMessageCount;
|
||||
|
||||
|
||||
// Detectar nova mensagem de outro usuário
|
||||
if (isNewMessage && mensagens.data.length > 0 && usuarioAtualId) {
|
||||
const ultimaMensagem = mensagens.data[mensagens.data.length - 1];
|
||||
const mensagemId = String(ultimaMensagem._id);
|
||||
const remetenteIdStr = ultimaMensagem.remetenteId
|
||||
? String(ultimaMensagem.remetenteId).trim()
|
||||
: (ultimaMensagem.remetente?._id ? String(ultimaMensagem.remetente._id).trim() : null);
|
||||
|
||||
const remetenteIdStr = ultimaMensagem.remetenteId
|
||||
? String(ultimaMensagem.remetenteId).trim()
|
||||
: ultimaMensagem.remetente?._id
|
||||
? String(ultimaMensagem.remetente._id).trim()
|
||||
: null;
|
||||
|
||||
// Se é uma nova mensagem de outro usuário (não minha) E ainda não foi notificada
|
||||
if (remetenteIdStr && remetenteIdStr !== usuarioAtualId && !mensagensNotificadas.has(mensagemId)) {
|
||||
if (
|
||||
remetenteIdStr &&
|
||||
remetenteIdStr !== usuarioAtualId &&
|
||||
!mensagensNotificadas.has(mensagemId)
|
||||
) {
|
||||
// Marcar como notificada antes de tocar som (evita duplicação)
|
||||
mensagensNotificadas.add(mensagemId);
|
||||
salvarMensagensNotificadas();
|
||||
|
||||
|
||||
// Tocar som de notificação (apenas uma vez)
|
||||
tocarSomNotificacao();
|
||||
|
||||
|
||||
// Mostrar popup de notificação
|
||||
notificationMessage = {
|
||||
remetente: ultimaMensagem.remetente?.nome || "Usuário",
|
||||
conteudo: ultimaMensagem.conteudo.substring(0, 100) + (ultimaMensagem.conteudo.length > 100 ? "..." : "")
|
||||
conteudo:
|
||||
ultimaMensagem.conteudo.substring(0, 100) +
|
||||
(ultimaMensagem.conteudo.length > 100 ? "..." : ""),
|
||||
};
|
||||
showNotificationPopup = true;
|
||||
|
||||
|
||||
// Ocultar popup após 5 segundos
|
||||
if (notificationTimeout) {
|
||||
clearTimeout(notificationTimeout);
|
||||
@@ -175,7 +205,7 @@
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (isNewMessage || shouldScrollToBottom) {
|
||||
// Usar requestAnimationFrame para garantir que o DOM foi atualizado
|
||||
requestAnimationFrame(() => {
|
||||
@@ -186,7 +216,7 @@
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
lastMessageCount = currentCount;
|
||||
}
|
||||
});
|
||||
@@ -195,9 +225,11 @@
|
||||
$effect(() => {
|
||||
if (mensagens?.data && mensagens.data.length > 0 && usuarioAtualId) {
|
||||
const ultimaMensagem = mensagens.data[mensagens.data.length - 1];
|
||||
const remetenteIdStr = ultimaMensagem.remetenteId
|
||||
? String(ultimaMensagem.remetenteId).trim()
|
||||
: (ultimaMensagem.remetente?._id ? String(ultimaMensagem.remetente._id).trim() : null);
|
||||
const remetenteIdStr = ultimaMensagem.remetenteId
|
||||
? String(ultimaMensagem.remetenteId).trim()
|
||||
: ultimaMensagem.remetente?._id
|
||||
? String(ultimaMensagem.remetente._id).trim()
|
||||
: null;
|
||||
// Só marcar como lida se não for minha mensagem
|
||||
if (remetenteIdStr && remetenteIdStr !== usuarioAtualId) {
|
||||
client.mutation(api.chat.marcarComoLida, {
|
||||
@@ -265,7 +297,9 @@
|
||||
lidaPor?: Id<"usuarios">[]; // IDs dos usuários que leram a mensagem
|
||||
}
|
||||
|
||||
function agruparMensagensPorDia(msgs: Mensagem[]): Record<string, Mensagem[]> {
|
||||
function agruparMensagensPorDia(
|
||||
msgs: Mensagem[],
|
||||
): Record<string, Mensagem[]> {
|
||||
const grupos: Record<string, Mensagem[]> = {};
|
||||
for (const msg of msgs) {
|
||||
const dia = formatarDiaMensagem(msg.enviadaEm);
|
||||
@@ -291,7 +325,9 @@
|
||||
});
|
||||
}
|
||||
|
||||
function getEmojisReacao(mensagem: Mensagem): Array<{ emoji: string; count: number }> {
|
||||
function getEmojisReacao(
|
||||
mensagem: Mensagem,
|
||||
): Array<{ emoji: string; count: number }> {
|
||||
if (!mensagem.reagiuPor || mensagem.reagiuPor.length === 0) return [];
|
||||
|
||||
const emojiMap: Record<string, number> = {};
|
||||
@@ -336,27 +372,33 @@
|
||||
novoConteudoEditado = "";
|
||||
}
|
||||
|
||||
async function deletarMensagem(mensagemId: Id<"mensagens">, isAdminDeleting: boolean = false) {
|
||||
const mensagemTexto = isAdminDeleting
|
||||
async function deletarMensagem(
|
||||
mensagemId: Id<"mensagens">,
|
||||
isAdminDeleting: boolean = false,
|
||||
) {
|
||||
const mensagemTexto = isAdminDeleting
|
||||
? "Tem certeza que deseja deletar esta mensagem como administrador? O remetente será notificado."
|
||||
: "Tem certeza que deseja deletar esta mensagem?";
|
||||
|
||||
|
||||
if (!confirm(mensagemTexto)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (isAdminDeleting) {
|
||||
const resultado = await client.mutation(api.chat.deletarMensagemComoAdmin, {
|
||||
mensagemId,
|
||||
});
|
||||
const resultado = await client.mutation(
|
||||
api.chat.deletarMensagemComoAdmin,
|
||||
{
|
||||
mensagemId,
|
||||
},
|
||||
);
|
||||
if (!resultado.sucesso) {
|
||||
alert(resultado.erro || "Erro ao deletar mensagem");
|
||||
}
|
||||
} else {
|
||||
await client.mutation(api.chat.deletarMensagem, {
|
||||
mensagemId,
|
||||
});
|
||||
await client.mutation(api.chat.deletarMensagem, {
|
||||
mensagemId,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Erro ao deletar mensagem:", error);
|
||||
@@ -393,7 +435,7 @@
|
||||
// Para conversas individuais: verificar se o outro participante leu
|
||||
if (conversa.tipo === "individual") {
|
||||
const outroParticipante = conversa.participantes?.find(
|
||||
(p: any) => String(p) !== usuarioAtualId
|
||||
(p: any) => String(p) !== usuarioAtualId,
|
||||
);
|
||||
if (outroParticipante) {
|
||||
return lidaPorStr.includes(String(outroParticipante));
|
||||
@@ -402,13 +444,16 @@
|
||||
|
||||
// Para grupos/salas: verificar se pelo menos um outro participante leu
|
||||
if (conversa.tipo === "grupo" || conversa.tipo === "sala_reuniao") {
|
||||
const outrosParticipantes = conversa.participantes?.filter(
|
||||
(p: any) => String(p) !== usuarioAtualId && String(p) !== String(mensagem.remetenteId)
|
||||
) || [];
|
||||
const outrosParticipantes =
|
||||
conversa.participantes?.filter(
|
||||
(p: any) =>
|
||||
String(p) !== usuarioAtualId &&
|
||||
String(p) !== String(mensagem.remetenteId),
|
||||
) || [];
|
||||
if (outrosParticipantes.length === 0) return false;
|
||||
// Verificar se pelo menos um outro participante leu
|
||||
return outrosParticipantes.some((p: any) =>
|
||||
lidaPorStr.includes(String(p))
|
||||
lidaPorStr.includes(String(p)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -426,7 +471,9 @@
|
||||
{#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">
|
||||
<div
|
||||
class="px-3 py-1 rounded-full bg-base-300 text-base-content/70 text-xs"
|
||||
>
|
||||
{dia}
|
||||
</div>
|
||||
</div>
|
||||
@@ -444,14 +491,17 @@
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
{@const isMinha = usuarioAtualId && remetenteIdStr && remetenteIdStr === usuarioAtualId}
|
||||
<div class={`flex mb-4 w-full ${isMinha ? "justify-end" : "justify-start"}`}>
|
||||
<div class={`flex flex-col max-w-[75%] ${isMinha ? "items-end" : "items-start"}`}>
|
||||
{@const isMinha =
|
||||
usuarioAtualId && remetenteIdStr && remetenteIdStr === usuarioAtualId}
|
||||
<div
|
||||
class={`flex mb-4 w-full ${isMinha ? "justify-end" : "justify-start"}`}
|
||||
>
|
||||
<div
|
||||
class={`flex flex-col max-w-[75%] ${isMinha ? "items-end" : "items-start"}`}
|
||||
>
|
||||
<!-- Nome do remetente (sempre exibido, mas discreto para mensagens próprias) -->
|
||||
{#if isMinha}
|
||||
<p class="text-xs text-base-content/40 mb-1 px-3">
|
||||
Você
|
||||
</p>
|
||||
<p class="text-xs text-base-content/40 mb-1 px-3">Você</p>
|
||||
{:else}
|
||||
<p class="text-xs text-base-content/60 mb-1 px-3">
|
||||
{mensagem.remetente?.nome || "Usuário"}
|
||||
@@ -468,13 +518,15 @@
|
||||
>
|
||||
{#if mensagem.mensagemOriginal}
|
||||
<!-- Preview da mensagem respondida -->
|
||||
<div class="mb-2 pl-3 border-l-2 border-base-content/20 opacity-70">
|
||||
<div
|
||||
class="mb-2 pl-3 border-l-2 border-base-content/20 opacity-70"
|
||||
>
|
||||
<p class="text-xs font-medium">
|
||||
{mensagem.mensagemOriginal.remetente?.nome || "Usuário"}
|
||||
</p>
|
||||
<p class="text-xs truncate">
|
||||
{mensagem.mensagemOriginal.deletada
|
||||
? "Mensagem deletada"
|
||||
{mensagem.mensagemOriginal.deletada
|
||||
? "Mensagem deletada"
|
||||
: mensagem.mensagemOriginal.conteudo}
|
||||
</p>
|
||||
</div>
|
||||
@@ -514,12 +566,16 @@
|
||||
{:else if mensagem.tipo === "texto"}
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-start gap-2">
|
||||
<p class="text-sm whitespace-pre-wrap break-words flex-1">{mensagem.conteudo}</p>
|
||||
<p class="text-sm whitespace-pre-wrap break-words flex-1">
|
||||
{mensagem.conteudo}
|
||||
</p>
|
||||
{#if mensagem.editadaEm}
|
||||
<span class="text-xs opacity-50 italic" title="Editado">(editado)</span>
|
||||
<span class="text-xs opacity-50 italic" title="Editado"
|
||||
>(editado)</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Preview de link -->
|
||||
{#if mensagem.linkPreview}
|
||||
<a
|
||||
@@ -534,19 +590,26 @@
|
||||
alt={mensagem.linkPreview.titulo || "Preview"}
|
||||
class="w-full h-48 object-cover"
|
||||
onerror={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
(e.target as HTMLImageElement).style.display =
|
||||
"none";
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
<div class="p-3 bg-base-200">
|
||||
{#if mensagem.linkPreview.site}
|
||||
<p class="text-xs text-base-content/50 mb-1">{mensagem.linkPreview.site}</p>
|
||||
<p class="text-xs text-base-content/50 mb-1">
|
||||
{mensagem.linkPreview.site}
|
||||
</p>
|
||||
{/if}
|
||||
{#if mensagem.linkPreview.titulo}
|
||||
<p class="text-sm font-medium text-base-content mb-1">{mensagem.linkPreview.titulo}</p>
|
||||
<p class="text-sm font-medium text-base-content mb-1">
|
||||
{mensagem.linkPreview.titulo}
|
||||
</p>
|
||||
{/if}
|
||||
{#if mensagem.linkPreview.descricao}
|
||||
<p class="text-xs text-base-content/70 line-clamp-2">{mensagem.linkPreview.descricao}</p>
|
||||
<p class="text-xs text-base-content/70 line-clamp-2">
|
||||
{mensagem.linkPreview.descricao}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
@@ -561,7 +624,9 @@
|
||||
/>
|
||||
</div>
|
||||
{#if mensagem.conteudo}
|
||||
<p class="text-sm whitespace-pre-wrap break-words">{mensagem.conteudo}</p>
|
||||
<p class="text-sm whitespace-pre-wrap break-words">
|
||||
{mensagem.conteudo}
|
||||
</p>
|
||||
{/if}
|
||||
{:else if mensagem.tipo === "arquivo"}
|
||||
<a
|
||||
@@ -604,7 +669,8 @@
|
||||
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}
|
||||
{reacao.emoji}
|
||||
{reacao.count}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -622,91 +688,91 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Timestamp e ações -->
|
||||
<div
|
||||
class={`flex items-center gap-2 mt-1 px-3 ${isMinha ? "justify-end" : "justify-start"}`}
|
||||
>
|
||||
<p class="text-xs text-base-content/50">
|
||||
{formatarDataMensagem(mensagem.enviadaEm)}
|
||||
</p>
|
||||
{#if isMinha && !mensagem.deletada && !mensagem.agendadaPara}
|
||||
<!-- Indicadores de status de envio e leitura -->
|
||||
<div class="flex items-center gap-0.5 ml-1">
|
||||
{#if mensagemFoiLida(mensagem)}
|
||||
<!-- Dois checks azuis para mensagem lida -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
class="w-3.5 h-3.5 text-blue-500"
|
||||
style="margin-left: -2px;"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M19.916 4.626a.75.75 0 0 1 .208 1.04l-9 13.5a.75.75 0 0 1-1.154.114l-6-6a.75.75 0 0 1 1.06-1.06l5.353 5.353 8.493-12.74a.75.75 0 0 1 1.04-.207Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
class="w-3.5 h-3.5 text-blue-500"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M19.916 4.626a.75.75 0 0 1 .208 1.04l-9 13.5a.75.75 0 0 1-1.154.114l-6-6a.75.75 0 0 1 1.06-1.06l5.353 5.353 8.493-12.74a.75.75 0 0 1 1.04-.207Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<!-- Um check verde para mensagem enviada -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
class="w-3.5 h-3.5 text-green-500"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M19.916 4.626a.75.75 0 0 1 .208 1.04l-9 13.5a.75.75 0 0 1-1.154.114l-6-6a.75.75 0 0 1 1.06-1.06l5.353 5.353 8.493-12.74a.75.75 0 0 1 1.04-.207Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if !mensagem.deletada && !mensagem.agendadaPara}
|
||||
<div class="flex gap-1">
|
||||
{#if isMinha}
|
||||
<!-- Ações para minhas próprias mensagens -->
|
||||
<button
|
||||
class="text-xs text-base-content/50 hover:text-primary transition-colors"
|
||||
onclick={() => editarMensagem(mensagem)}
|
||||
title="Editar mensagem"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
<button
|
||||
class="text-xs text-base-content/50 hover:text-error transition-colors"
|
||||
onclick={() => deletarMensagem(mensagem._id, false)}
|
||||
title="Deletar mensagem"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
{:else if isAdmin?.data}
|
||||
<!-- Ações para admin deletar mensagens de outros -->
|
||||
<button
|
||||
class="text-xs text-base-content/50 hover:text-error transition-colors"
|
||||
onclick={() => deletarMensagem(mensagem._id, true)}
|
||||
title="Deletar mensagem (como administrador)"
|
||||
>
|
||||
🗑️ Admin
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Timestamp e ações -->
|
||||
<div
|
||||
class={`flex items-center gap-2 mt-1 px-3 ${isMinha ? "justify-end" : "justify-start"}`}
|
||||
>
|
||||
<p class="text-xs text-base-content/50">
|
||||
{formatarDataMensagem(mensagem.enviadaEm)}
|
||||
</p>
|
||||
{#if isMinha && !mensagem.deletada && !mensagem.agendadaPara}
|
||||
<!-- Indicadores de status de envio e leitura -->
|
||||
<div class="flex items-center gap-0.5 ml-1">
|
||||
{#if mensagemFoiLida(mensagem)}
|
||||
<!-- Dois checks azuis para mensagem lida -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
class="w-3.5 h-3.5 text-blue-500"
|
||||
style="margin-left: -2px;"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M19.916 4.626a.75.75 0 0 1 .208 1.04l-9 13.5a.75.75 0 0 1-1.154.114l-6-6a.75.75 0 0 1 1.06-1.06l5.353 5.353 8.493-12.74a.75.75 0 0 1 1.04-.207Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
class="w-3.5 h-3.5 text-blue-500"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M19.916 4.626a.75.75 0 0 1 .208 1.04l-9 13.5a.75.75 0 0 1-1.154.114l-6-6a.75.75 0 0 1 1.06-1.06l5.353 5.353 8.493-12.74a.75.75 0 0 1 1.04-.207Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<!-- Um check verde para mensagem enviada -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
class="w-3.5 h-3.5 text-green-500"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M19.916 4.626a.75.75 0 0 1 .208 1.04l-9 13.5a.75.75 0 0 1-1.154.114l-6-6a.75.75 0 0 1 1.06-1.06l5.353 5.353 8.493-12.74a.75.75 0 0 1 1.04-.207Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if !mensagem.deletada && !mensagem.agendadaPara}
|
||||
<div class="flex gap-1">
|
||||
{#if isMinha}
|
||||
<!-- Ações para minhas próprias mensagens -->
|
||||
<button
|
||||
class="text-xs text-base-content/50 hover:text-primary transition-colors"
|
||||
onclick={() => editarMensagem(mensagem)}
|
||||
title="Editar mensagem"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
<button
|
||||
class="text-xs text-base-content/50 hover:text-error transition-colors"
|
||||
onclick={() => deletarMensagem(mensagem._id, false)}
|
||||
title="Deletar mensagem"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
{:else if isAdmin?.data}
|
||||
<!-- Ações para admin deletar mensagens de outros -->
|
||||
<button
|
||||
class="text-xs text-base-content/50 hover:text-error transition-colors"
|
||||
onclick={() => deletarMensagem(mensagem._id, true)}
|
||||
title="Deletar mensagem (como administrador)"
|
||||
>
|
||||
🗑️ Admin
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
@@ -716,7 +782,9 @@
|
||||
{#if digitando?.data && digitando.data.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"
|
||||
></div>
|
||||
<div
|
||||
class="w-2 h-2 rounded-full bg-base-content/50 animate-bounce"
|
||||
style="animation-delay: 0.1s;"
|
||||
@@ -727,7 +795,8 @@
|
||||
></div>
|
||||
</div>
|
||||
<p class="text-xs text-base-content/60">
|
||||
{digitando.data.map((u: { nome: string }) => u.nome).join(", ")} {digitando.data.length === 1
|
||||
{digitando.data.map((u: { nome: string }) => u.nome).join(", ")}
|
||||
{digitando.data.length === 1
|
||||
? "está digitando"
|
||||
: "estão digitando"}...
|
||||
</p>
|
||||
@@ -756,7 +825,9 @@
|
||||
/>
|
||||
</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>
|
||||
<p class="text-sm text-base-content/50 mt-1">
|
||||
Envie a primeira mensagem!
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -775,7 +846,9 @@
|
||||
}}
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0 w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center">
|
||||
<div
|
||||
class="shrink-0 w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
@@ -784,16 +857,24 @@
|
||||
stroke="currentColor"
|
||||
class="w-5 h-5 text-primary"
|
||||
>
|
||||
<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" />
|
||||
<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>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-semibold text-base-content text-sm mb-1">Nova mensagem de {notificationMessage.remetente}</p>
|
||||
<p class="text-xs text-base-content/70 line-clamp-2">{notificationMessage.conteudo}</p>
|
||||
<p class="font-semibold text-base-content text-sm mb-1">
|
||||
Nova mensagem de {notificationMessage.remetente}
|
||||
</p>
|
||||
<p class="text-xs text-base-content/70 line-clamp-2">
|
||||
{notificationMessage.conteudo}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-shrink-0 w-6 h-6 rounded-full hover:bg-base-200 flex items-center justify-center transition-colors"
|
||||
class="shrink-0 w-6 h-6 rounded-full hover:bg-base-200 flex items-center justify-center transition-colors"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
showNotificationPopup = false;
|
||||
@@ -803,11 +884,21 @@
|
||||
}
|
||||
}}
|
||||
>
|
||||
<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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
<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"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M6 18 18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -2,10 +2,19 @@
|
||||
import { useQuery, useConvexClient } from "convex-svelte";
|
||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||
import { abrirConversa } from "$lib/stores/chatStore";
|
||||
import { authStore } from "$lib/stores/auth.svelte";
|
||||
import UserStatusBadge from "./UserStatusBadge.svelte";
|
||||
import UserAvatar from "./UserAvatar.svelte";
|
||||
import { MessageSquare, User, Users, Video, X, Search, ChevronRight, Plus, UserX } from "lucide-svelte";
|
||||
import {
|
||||
MessageSquare,
|
||||
User,
|
||||
Users,
|
||||
Video,
|
||||
X,
|
||||
Search,
|
||||
ChevronRight,
|
||||
Plus,
|
||||
UserX,
|
||||
} from "lucide-svelte";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
@@ -16,6 +25,8 @@
|
||||
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("");
|
||||
@@ -26,27 +37,36 @@
|
||||
|
||||
const usuariosFiltrados = $derived(() => {
|
||||
if (!usuarios?.data) return [];
|
||||
|
||||
|
||||
// Filtrar o próprio usuário
|
||||
const meuId = authStore.usuario?._id || meuPerfil?.data?._id;
|
||||
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: any) =>
|
||||
u.nome?.toLowerCase().includes(query) ||
|
||||
u.email?.toLowerCase().includes(query) ||
|
||||
u.matricula?.toLowerCase().includes(query)
|
||||
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: 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;
|
||||
|
||||
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 || "");
|
||||
});
|
||||
@@ -99,7 +119,8 @@
|
||||
onClose();
|
||||
} catch (error: any) {
|
||||
console.error("Erro ao criar grupo:", error);
|
||||
const mensagem = error?.message || error?.data || "Erro desconhecido ao criar grupo";
|
||||
const mensagem =
|
||||
error?.message || error?.data || "Erro desconhecido ao criar grupo";
|
||||
alert(`Erro ao criar grupo: ${mensagem}`);
|
||||
} finally {
|
||||
loading = false;
|
||||
@@ -127,7 +148,10 @@
|
||||
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";
|
||||
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;
|
||||
@@ -135,10 +159,18 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<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()}>
|
||||
<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">
|
||||
<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
|
||||
@@ -158,8 +190,8 @@
|
||||
<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"
|
||||
activeTab === "individual"
|
||||
? "tab-active bg-primary text-primary-content font-semibold"
|
||||
: "hover:bg-base-300"
|
||||
}`}
|
||||
onclick={() => {
|
||||
@@ -174,8 +206,8 @@
|
||||
<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"
|
||||
activeTab === "grupo"
|
||||
? "tab-active bg-primary text-primary-content font-semibold"
|
||||
: "hover:bg-base-300"
|
||||
}`}
|
||||
onclick={() => {
|
||||
@@ -190,8 +222,8 @@
|
||||
<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"
|
||||
activeTab === "sala_reuniao"
|
||||
? "tab-active bg-primary text-primary-content font-semibold"
|
||||
: "hover:bg-base-300"
|
||||
}`}
|
||||
onclick={() => {
|
||||
@@ -225,7 +257,9 @@
|
||||
<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' : ''})` : ""}
|
||||
Participantes {selectedUsers.length > 0
|
||||
? `(${selectedUsers.length} selecionado${selectedUsers.length > 1 ? "s" : ""})`
|
||||
: ""}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
@@ -233,8 +267,11 @@
|
||||
<!-- 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>
|
||||
<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"
|
||||
@@ -248,7 +285,9 @@
|
||||
<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' : ''})` : ""}
|
||||
Participantes {selectedUsers.length > 0
|
||||
? `(${selectedUsers.length} selecionado${selectedUsers.length > 1 ? "s" : ""})`
|
||||
: ""}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
@@ -262,7 +301,9 @@
|
||||
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" />
|
||||
<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 -->
|
||||
@@ -288,29 +329,37 @@
|
||||
disabled={loading}
|
||||
>
|
||||
<!-- Avatar -->
|
||||
<div class="relative flex-shrink-0">
|
||||
<UserAvatar
|
||||
<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" />
|
||||
<UserStatusBadge
|
||||
status={usuario.statusPresenca || "offline"}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-semibold text-base-content truncate">{usuario.nome}</p>
|
||||
<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"}
|
||||
{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="flex-shrink-0">
|
||||
<div class="shrink-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-primary checkbox-lg"
|
||||
@@ -326,17 +375,24 @@
|
||||
{/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>
|
||||
<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">
|
||||
<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"}
|
||||
{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>
|
||||
<p class="text-sm text-base-content/50 mt-2">
|
||||
Tente buscar por nome, email ou matrícula
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -361,7 +417,9 @@
|
||||
{/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>
|
||||
<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"}
|
||||
@@ -370,7 +428,9 @@
|
||||
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()}
|
||||
disabled={loading ||
|
||||
selectedUsers.length < 1 ||
|
||||
!salaReuniaoName.trim()}
|
||||
>
|
||||
{#if loading}
|
||||
<span class="loading loading-spinner"></span>
|
||||
@@ -381,7 +441,9 @@
|
||||
{/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>
|
||||
<p class="text-xs text-base-content/50 text-center mt-2">
|
||||
Selecione pelo menos 1 participante
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -390,4 +452,3 @@
|
||||
<button type="button" onclick={onClose}>fechar</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { ptBR } from "date-fns/locale";
|
||||
import { onMount } from "svelte";
|
||||
import { authStore } from "$lib/stores/auth.svelte";
|
||||
import {
|
||||
Bell,
|
||||
Mail,
|
||||
@@ -26,6 +25,8 @@
|
||||
const todasNotificacoesQuery = useQuery(api.chat.obterNotificacoes, {
|
||||
apenasPendentes: false,
|
||||
});
|
||||
// Usuário atual
|
||||
const currentUser = useQuery(api.auth.getCurrentUser, {});
|
||||
|
||||
let modalOpen = $state(false);
|
||||
let notificacoesFerias = $state<
|
||||
@@ -74,13 +75,12 @@
|
||||
// Buscar notificações de férias
|
||||
async function buscarNotificacoesFerias() {
|
||||
try {
|
||||
const usuarioStore = authStore;
|
||||
|
||||
if (usuarioStore.usuario?._id) {
|
||||
const usuarioId = currentUser?.data?._id;
|
||||
if (usuarioId) {
|
||||
const notifsFerias = await client.query(
|
||||
api.ferias.obterNotificacoesNaoLidas,
|
||||
{
|
||||
usuarioId: usuarioStore.usuario._id,
|
||||
usuarioId,
|
||||
},
|
||||
);
|
||||
notificacoesFerias = notifsFerias || [];
|
||||
@@ -93,14 +93,13 @@
|
||||
// Buscar notificações de ausências
|
||||
async function buscarNotificacoesAusencias() {
|
||||
try {
|
||||
const usuarioStore = authStore;
|
||||
|
||||
if (usuarioStore.usuario?._id) {
|
||||
const usuarioId = currentUser?.data?._id;
|
||||
if (usuarioId) {
|
||||
try {
|
||||
const notifsAusencias = await client.query(
|
||||
api.ausencias.obterNotificacoesNaoLidas,
|
||||
{
|
||||
usuarioId: usuarioStore.usuario._id,
|
||||
usuarioId,
|
||||
},
|
||||
);
|
||||
notificacoesAusencias = notifsAusencias || [];
|
||||
@@ -366,7 +365,7 @@
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<!-- Ícone -->
|
||||
<div class="flex-shrink-0 mt-1">
|
||||
<div class="shrink-0 mt-1">
|
||||
{#if notificacao.tipo === "nova_mensagem"}
|
||||
<Mail class="w-5 h-5 text-primary" strokeWidth={1.5} />
|
||||
{:else if notificacao.tipo === "mencao"}
|
||||
@@ -415,7 +414,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Indicador de não lida -->
|
||||
<div class="flex-shrink-0">
|
||||
<div class="shrink-0">
|
||||
<div class="w-2 h-2 rounded-full bg-primary"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -438,7 +437,7 @@
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<!-- Ícone -->
|
||||
<div class="flex-shrink-0 mt-1">
|
||||
<div class="shrink-0 mt-1">
|
||||
{#if notificacao.tipo === "nova_mensagem"}
|
||||
<Mail
|
||||
class="w-5 h-5 text-primary/60"
|
||||
@@ -508,7 +507,7 @@
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<!-- Ícone -->
|
||||
<div class="flex-shrink-0 mt-1">
|
||||
<div class="shrink-0 mt-1">
|
||||
<Calendar
|
||||
class="w-5 h-5 text-purple-600"
|
||||
strokeWidth={2}
|
||||
@@ -526,7 +525,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Badge -->
|
||||
<div class="flex-shrink-0">
|
||||
<div class="shrink-0">
|
||||
<div class="badge badge-primary badge-xs"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -550,7 +549,7 @@
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<!-- Ícone -->
|
||||
<div class="flex-shrink-0 mt-1">
|
||||
<div class="shrink-0 mt-1">
|
||||
<Clock class="w-5 h-5 text-orange-600" strokeWidth={2} />
|
||||
</div>
|
||||
|
||||
@@ -565,7 +564,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Badge -->
|
||||
<div class="flex-shrink-0">
|
||||
<div class="shrink-0">
|
||||
<div class="badge badge-warning badge-xs"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { useConvexClient } from "convex-svelte";
|
||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||
import { onMount } from "svelte";
|
||||
import { authStore } from "$lib/stores/auth.svelte";
|
||||
|
||||
const client = useConvexClient();
|
||||
|
||||
|
||||
@@ -4,7 +4,15 @@
|
||||
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 {
|
||||
X,
|
||||
Users,
|
||||
UserPlus,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
Trash2,
|
||||
Search,
|
||||
} from "lucide-svelte";
|
||||
|
||||
interface Props {
|
||||
conversaId: Id<"conversas">;
|
||||
@@ -37,17 +45,18 @@
|
||||
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 [];
|
||||
|
||||
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;
|
||||
|
||||
|
||||
const usuario = usuarios.find((u: any) => {
|
||||
try {
|
||||
return String(u?._id) === String(participanteId);
|
||||
@@ -56,13 +65,15 @@
|
||||
}
|
||||
});
|
||||
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 : {}),
|
||||
...(typeof p === "object" && p !== null && p !== undefined
|
||||
? p
|
||||
: {}),
|
||||
// Garantir que _id existe e priorizar o do usuario
|
||||
_id: usuario._id
|
||||
_id: usuario._id,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("Erro ao processar participante:", err, p);
|
||||
@@ -84,17 +95,21 @@
|
||||
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)));
|
||||
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)
|
||||
return disponiveis.filter(
|
||||
(u: any) =>
|
||||
(u.nome || "").toLowerCase().includes(query) ||
|
||||
(u.email || "").toLowerCase().includes(query) ||
|
||||
(u.matricula || "").toLowerCase().includes(query),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -114,10 +129,13 @@
|
||||
try {
|
||||
loading = `remover-${participanteId}`;
|
||||
error = null;
|
||||
const resultado = await client.mutation(api.chat.removerParticipanteSala, {
|
||||
conversaId,
|
||||
participanteId: participanteId as any,
|
||||
});
|
||||
const resultado = await client.mutation(
|
||||
api.chat.removerParticipanteSala,
|
||||
{
|
||||
conversaId,
|
||||
participanteId: participanteId as any,
|
||||
},
|
||||
);
|
||||
|
||||
if (!resultado.sucesso) {
|
||||
error = resultado.erro || "Erro ao remover participante";
|
||||
@@ -175,10 +193,13 @@
|
||||
try {
|
||||
loading = `adicionar-${usuarioId}`;
|
||||
error = null;
|
||||
const resultado = await client.mutation(api.chat.adicionarParticipanteSala, {
|
||||
conversaId,
|
||||
participanteId: usuarioId as any,
|
||||
});
|
||||
const resultado = await client.mutation(
|
||||
api.chat.adicionarParticipanteSala,
|
||||
{
|
||||
conversaId,
|
||||
participanteId: usuarioId as any,
|
||||
},
|
||||
);
|
||||
|
||||
if (!resultado.sucesso) {
|
||||
error = resultado.erro || "Erro ao adicionar participante";
|
||||
@@ -193,16 +214,26 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<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()}>
|
||||
<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
|
||||
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>
|
||||
<p class="text-sm text-base-content/60">
|
||||
{conversa()?.nome || "Sem nome"}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -240,7 +271,11 @@
|
||||
{#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)}>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-ghost"
|
||||
onclick={() => (error = null)}
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -252,13 +287,17 @@
|
||||
<!-- 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>
|
||||
<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>
|
||||
<span class="ml-2 text-sm text-base-content/60"
|
||||
>Carregando usuários...</span
|
||||
>
|
||||
</div>
|
||||
{:else if activeTab === "participantes"}
|
||||
<!-- Lista de Participantes -->
|
||||
@@ -273,27 +312,34 @@
|
||||
class="flex items-center gap-3 p-3 rounded-lg border border-base-300 hover:bg-base-200 transition-colors"
|
||||
>
|
||||
<!-- Avatar -->
|
||||
<div class="relative flex-shrink-0">
|
||||
<div class="relative shrink-0">
|
||||
<UserAvatar
|
||||
avatar={participante.avatar}
|
||||
fotoPerfilUrl={participante.fotoPerfilUrl || participante.fotoPerfil}
|
||||
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" />
|
||||
<UserStatusBadge
|
||||
status={participante.statusPresenca || "offline"}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
<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>
|
||||
<span class="badge badge-secondary badge-sm">Criador</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-sm text-base-content/60 truncate">
|
||||
@@ -313,7 +359,8 @@
|
||||
title="Rebaixar administrador"
|
||||
>
|
||||
{#if isLoading && loading?.includes("rebaixar")}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
<span class="loading loading-spinner loading-xs"
|
||||
></span>
|
||||
{:else}
|
||||
<ArrowDown class="w-4 h-4" />
|
||||
{/if}
|
||||
@@ -327,7 +374,8 @@
|
||||
title="Promover a administrador"
|
||||
>
|
||||
{#if isLoading && loading?.includes("promover")}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
<span class="loading loading-spinner loading-xs"
|
||||
></span>
|
||||
{:else}
|
||||
<ArrowUp class="w-4 h-4" />
|
||||
{/if}
|
||||
@@ -365,7 +413,9 @@
|
||||
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" />
|
||||
<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">
|
||||
@@ -380,7 +430,7 @@
|
||||
disabled={isLoading}
|
||||
>
|
||||
<!-- Avatar -->
|
||||
<div class="relative flex-shrink-0">
|
||||
<div class="relative shrink-0">
|
||||
<UserAvatar
|
||||
avatar={usuario.avatar}
|
||||
fotoPerfilUrl={usuario.fotoPerfilUrl || usuario.fotoPerfil}
|
||||
@@ -388,13 +438,18 @@
|
||||
size="sm"
|
||||
/>
|
||||
<div class="absolute bottom-0 right-0">
|
||||
<UserStatusBadge status={usuario.statusPresenca || "offline"} size="sm" />
|
||||
<UserStatusBadge
|
||||
status={usuario.statusPresenca || "offline"}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-medium text-base-content truncate">{usuario.nome || "Usuário"}</p>
|
||||
<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>
|
||||
@@ -410,7 +465,9 @@
|
||||
{/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"}
|
||||
{searchQuery.trim()
|
||||
? "Nenhum usuário encontrado"
|
||||
: "Todos os usuários já são participantes"}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -428,4 +485,3 @@
|
||||
<button type="button" onclick={onClose}>fechar</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
|
||||
@@ -14,16 +14,21 @@
|
||||
let { conversaId, onClose }: Props = $props();
|
||||
|
||||
const client = useConvexClient();
|
||||
const mensagensAgendadas = useQuery(api.chat.obterMensagensAgendadas, { conversaId });
|
||||
const mensagensAgendadas = useQuery(api.chat.obterMensagensAgendadas, {
|
||||
conversaId,
|
||||
});
|
||||
|
||||
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);
|
||||
console.log(
|
||||
"📅 [ScheduleModal] Mensagens agendadas atualizadas:",
|
||||
mensagensAgendadas?.data,
|
||||
);
|
||||
});
|
||||
|
||||
// Definir data/hora mínima (agora)
|
||||
@@ -33,7 +38,7 @@
|
||||
|
||||
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 })}`;
|
||||
@@ -51,7 +56,7 @@
|
||||
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");
|
||||
@@ -67,7 +72,7 @@
|
||||
mensagem = "";
|
||||
data = "";
|
||||
hora = "";
|
||||
|
||||
|
||||
// Dar tempo para o Convex processar e recarregar a lista
|
||||
setTimeout(() => {
|
||||
alert("Mensagem agendada com sucesso!");
|
||||
@@ -84,7 +89,9 @@
|
||||
if (!confirm("Deseja cancelar esta mensagem agendada?")) return;
|
||||
|
||||
try {
|
||||
await client.mutation(api.chat.cancelarMensagemAgendada, { mensagemId: mensagemId as any });
|
||||
await client.mutation(api.chat.cancelarMensagemAgendada, {
|
||||
mensagemId: mensagemId as any,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Erro ao cancelar mensagem:", error);
|
||||
alert("Erro ao cancelar mensagem");
|
||||
@@ -93,17 +100,27 @@
|
||||
|
||||
function formatarDataHora(timestamp: number): string {
|
||||
try {
|
||||
return format(new Date(timestamp), "dd/MM/yyyy 'às' HH:mm", { locale: ptBR });
|
||||
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 max-w-2xl max-h-[90vh] flex flex-col p-0" onclick={(e) => e.stopPropagation()}>
|
||||
<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">
|
||||
<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
|
||||
@@ -124,7 +141,7 @@
|
||||
<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>
|
||||
@@ -138,7 +155,9 @@
|
||||
aria-describedby="char-count"
|
||||
></textarea>
|
||||
<div class="label">
|
||||
<span id="char-count" class="label-text-alt">{mensagem.length}/500</span>
|
||||
<span id="char-count" class="label-text-alt"
|
||||
>{mensagem.length}/500</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -187,15 +206,21 @@
|
||||
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="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="w-5 h-5 group-hover:scale-110 transition-transform" />
|
||||
<span class="group-hover:scale-105 transition-transform">Agendar</span>
|
||||
<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>
|
||||
@@ -207,15 +232,15 @@
|
||||
<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="flex items-start gap-3 p-3 bg-base-100 rounded-lg">
|
||||
<div class="flex-shrink-0 mt-1">
|
||||
<div class="shrink-0 mt-1">
|
||||
<Clock class="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-base-content/80">
|
||||
{formatarDataHora(msg.agendadaPara || 0)}
|
||||
@@ -233,8 +258,12 @@
|
||||
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" />
|
||||
<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}
|
||||
|
||||
Reference in New Issue
Block a user