feat: enhance ErrorModal and chat components with new features and improvements
- Refactored ErrorModal to utilize a dialog element for better accessibility and user experience, including a close button with an icon. - Updated chat components to improve participant display and message read status, enhancing user engagement and clarity. - Introduced loading indicators for user and conversation data in SalaReuniaoManager to improve responsiveness during data fetching. - Enhanced message handling in MessageList to indicate whether messages have been read, providing users with better feedback on message status. - Improved overall structure and styling across various components for consistency and maintainability.
This commit is contained in:
@@ -168,22 +168,27 @@
|
||||
{conversa()?.participantesInfo?.length || 0} {conversa()?.participantesInfo?.length === 1 ? "participante" : "participantes"}
|
||||
</p>
|
||||
{#if conversa()?.participantesInfo && conversa()?.participantesInfo.length > 0}
|
||||
<div class="flex -space-x-2">
|
||||
{#each conversa()?.participantesInfo.slice(0, 5) as participante (participante._id)}
|
||||
<div class="relative w-5 h-5 rounded-full border-2 border-base-200 overflow-hidden bg-base-200" title={participante.nome}>
|
||||
{#if participante.fotoPerfilUrl}
|
||||
<img src={participante.fotoPerfilUrl} alt={participante.nome} class="w-full h-full object-cover" />
|
||||
{:else if participante.avatar}
|
||||
<img src={getAvatarUrl(participante.avatar)} alt={participante.nome} class="w-full h-full object-cover" />
|
||||
{:else}
|
||||
<img src={getAvatarUrl(participante.nome)} alt={participante.nome} class="w-full h-full object-cover" />
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{#if conversa()?.participantesInfo.length > 5}
|
||||
<div class="w-5 h-5 rounded-full border-2 border-base-200 bg-base-300 flex items-center justify-center text-[8px] font-semibold text-base-content/70" title={`+${conversa()?.participantesInfo.length - 5} mais`}>
|
||||
+{conversa()?.participantesInfo.length - 5}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex -space-x-2">
|
||||
{#each conversa()?.participantesInfo.slice(0, 5) as participante (participante._id)}
|
||||
<div class="relative w-5 h-5 rounded-full border-2 border-base-200 overflow-hidden bg-base-200" title={participante.nome}>
|
||||
{#if participante.fotoPerfilUrl}
|
||||
<img src={participante.fotoPerfilUrl} alt={participante.nome} class="w-full h-full object-cover" />
|
||||
{:else if participante.avatar}
|
||||
<img src={getAvatarUrl(participante.avatar)} alt={participante.nome} class="w-full h-full object-cover" />
|
||||
{:else}
|
||||
<img src={getAvatarUrl(participante.nome)} alt={participante.nome} class="w-full h-full object-cover" />
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{#if conversa()?.participantesInfo.length > 5}
|
||||
<div class="w-5 h-5 rounded-full border-2 border-base-200 bg-base-300 flex items-center justify-center text-[8px] font-semibold text-base-content/70" title={`+${conversa()?.participantesInfo.length - 5} mais`}>
|
||||
+{conversa()?.participantesInfo.length - 5}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if conversa()?.tipo === "sala_reuniao" && isAdmin?.data}
|
||||
<span class="text-[10px] text-primary font-semibold ml-1 whitespace-nowrap" title="Você é administrador desta sala">• Admin</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
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, {});
|
||||
|
||||
let messagesContainer: HTMLDivElement;
|
||||
let shouldScrollToBottom = true;
|
||||
@@ -236,6 +237,7 @@
|
||||
editadaEm?: number;
|
||||
deletada?: boolean;
|
||||
agendadaPara?: number;
|
||||
minutosPara?: number;
|
||||
respostaPara?: Id<"mensagens">;
|
||||
mensagemOriginal?: {
|
||||
_id: Id<"mensagens">;
|
||||
@@ -260,6 +262,7 @@
|
||||
imagem?: string;
|
||||
site?: string;
|
||||
} | null;
|
||||
lidaPor?: Id<"usuarios">[]; // IDs dos usuários que leram a mensagem
|
||||
}
|
||||
|
||||
function agruparMensagensPorDia(msgs: Mensagem[]): Record<string, Mensagem[]> {
|
||||
@@ -369,6 +372,48 @@
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
}
|
||||
|
||||
// Obter informações da conversa atual
|
||||
const conversaAtual = $derived(() => {
|
||||
if (!conversas?.data) return null;
|
||||
return (conversas.data as any[]).find((c) => c._id === conversaId) || null;
|
||||
});
|
||||
|
||||
// Função para determinar se uma mensagem foi lida
|
||||
function mensagemFoiLida(mensagem: Mensagem): boolean {
|
||||
if (!mensagem.lidaPor || mensagem.lidaPor.length === 0) return false;
|
||||
if (!conversaAtual() || !usuarioAtualId) return false;
|
||||
|
||||
const conversa = conversaAtual();
|
||||
if (!conversa) return false;
|
||||
|
||||
// Converter lidaPor para strings para comparação
|
||||
const lidaPorStr = mensagem.lidaPor.map((id) => String(id));
|
||||
|
||||
// Para conversas individuais: verificar se o outro participante leu
|
||||
if (conversa.tipo === "individual") {
|
||||
const outroParticipante = conversa.participantes?.find(
|
||||
(p: any) => String(p) !== usuarioAtualId
|
||||
);
|
||||
if (outroParticipante) {
|
||||
return lidaPorStr.includes(String(outroParticipante));
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
) || [];
|
||||
if (outrosParticipantes.length === 0) return false;
|
||||
// Verificar se pelo menos um outro participante leu
|
||||
return outrosParticipantes.some((p: any) =>
|
||||
lidaPorStr.includes(String(p))
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -584,6 +629,53 @@
|
||||
<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}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
const client = useConvexClient();
|
||||
const conversas = useQuery(api.chat.listarConversas, {});
|
||||
const todosUsuarios = useQuery(api.chat.listarTodosUsuarios, {});
|
||||
const todosUsuariosQuery = useQuery(api.chat.listarTodosUsuarios, {});
|
||||
|
||||
let activeTab = $state<"participantes" | "adicionar">("participantes");
|
||||
let searchQuery = $state("");
|
||||
@@ -28,15 +28,52 @@
|
||||
return conversas.data.find((c: any) => c._id === conversaId);
|
||||
});
|
||||
|
||||
const todosUsuarios = $derived(() => {
|
||||
return todosUsuariosQuery?.data || [];
|
||||
});
|
||||
|
||||
const participantes = $derived(() => {
|
||||
if (!conversa() || !todosUsuarios) return [];
|
||||
const participantesIds = conversa()?.participantesInfo || [];
|
||||
return participantesIds
|
||||
.map((p: any) => {
|
||||
const usuario = todosUsuarios.find((u: any) => u._id === p._id);
|
||||
return usuario ? { ...usuario, ...p } : null;
|
||||
})
|
||||
.filter((p: any) => p !== null);
|
||||
try {
|
||||
const conv = conversa();
|
||||
const usuarios = todosUsuarios();
|
||||
if (!conv || !usuarios || usuarios.length === 0) return [];
|
||||
|
||||
const participantesInfo = conv.participantesInfo || [];
|
||||
if (!Array.isArray(participantesInfo) || participantesInfo.length === 0) return [];
|
||||
|
||||
return participantesInfo
|
||||
.map((p: any) => {
|
||||
try {
|
||||
// p pode ser um objeto com _id ou apenas um ID
|
||||
const participanteId = p?._id || p;
|
||||
if (!participanteId) return null;
|
||||
|
||||
const usuario = usuarios.find((u: any) => {
|
||||
try {
|
||||
return String(u?._id) === String(participanteId);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
if (!usuario) return null;
|
||||
|
||||
// Combinar dados do usuário com dados do participante (se p for objeto)
|
||||
return {
|
||||
...usuario,
|
||||
...(typeof p === 'object' && p !== null && p !== undefined ? p : {}),
|
||||
// Garantir que _id existe e priorizar o do usuario
|
||||
_id: usuario._id
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("Erro ao processar participante:", err, p);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((p: any) => p !== null && p._id);
|
||||
} catch (err) {
|
||||
console.error("Erro ao calcular participantes:", err);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
const administradoresIds = $derived(() => {
|
||||
@@ -44,27 +81,31 @@
|
||||
});
|
||||
|
||||
const usuariosDisponiveis = $derived(() => {
|
||||
if (!todosUsuarios) return [];
|
||||
const usuarios = todosUsuarios();
|
||||
if (!usuarios || usuarios.length === 0) return [];
|
||||
const participantesIds = conversa()?.participantes || [];
|
||||
return todosUsuarios.filter((u: any) => !participantesIds.includes(u._id));
|
||||
return usuarios.filter((u: any) => !participantesIds.some((pid: any) => String(pid) === String(u._id)));
|
||||
});
|
||||
|
||||
const usuariosFiltrados = $derived(() => {
|
||||
if (!searchQuery.trim()) return usuariosDisponiveis();
|
||||
const disponiveis = usuariosDisponiveis();
|
||||
if (!searchQuery.trim()) return disponiveis;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return usuariosDisponiveis().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)
|
||||
);
|
||||
});
|
||||
|
||||
function isParticipanteAdmin(usuarioId: string): boolean {
|
||||
return administradoresIds().includes(usuarioId as any);
|
||||
const admins = administradoresIds();
|
||||
return admins.some((adminId: any) => String(adminId) === String(usuarioId));
|
||||
}
|
||||
|
||||
function isCriador(usuarioId: string): boolean {
|
||||
return conversa()?.criadoPor === usuarioId;
|
||||
const criadoPor = conversa()?.criadoPor;
|
||||
return criadoPor ? String(criadoPor) === String(usuarioId) : false;
|
||||
}
|
||||
|
||||
async function removerParticipante(participanteId: string) {
|
||||
@@ -207,14 +248,27 @@
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 overflow-y-auto px-6">
|
||||
{#if activeTab === "participantes"}
|
||||
{#if !conversas?.data}
|
||||
<!-- Loading conversas -->
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
<span class="ml-2 text-sm text-base-content/60">Carregando conversa...</span>
|
||||
</div>
|
||||
{:else if !todosUsuariosQuery?.data}
|
||||
<!-- Loading usuários -->
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
<span class="ml-2 text-sm text-base-content/60">Carregando usuários...</span>
|
||||
</div>
|
||||
{:else if activeTab === "participantes"}
|
||||
<!-- Lista de Participantes -->
|
||||
<div class="space-y-2 py-2">
|
||||
{#if participantes().length > 0}
|
||||
{#each participantes() as participante (participante._id)}
|
||||
{@const ehAdmin = isParticipanteAdmin(participante._id)}
|
||||
{@const ehCriador = isCriador(participante._id)}
|
||||
{@const isLoading = loading?.includes(participante._id)}
|
||||
{#each participantes() as participante (String(participante._id))}
|
||||
{@const participanteId = String(participante._id)}
|
||||
{@const ehAdmin = isParticipanteAdmin(participanteId)}
|
||||
{@const ehCriador = isCriador(participanteId)}
|
||||
{@const isLoading = loading?.includes(participanteId)}
|
||||
<div
|
||||
class="flex items-center gap-3 p-3 rounded-lg border border-base-300 hover:bg-base-200 transition-colors"
|
||||
>
|
||||
@@ -222,19 +276,19 @@
|
||||
<div class="relative flex-shrink-0">
|
||||
<UserAvatar
|
||||
avatar={participante.avatar}
|
||||
fotoPerfilUrl={participante.fotoPerfilUrl}
|
||||
nome={participante.nome}
|
||||
fotoPerfilUrl={participante.fotoPerfilUrl || participante.fotoPerfil}
|
||||
nome={participante.nome || "Usuário"}
|
||||
size="sm"
|
||||
/>
|
||||
<div class="absolute bottom-0 right-0">
|
||||
<UserStatusBadge status={participante.statusPresenca} 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}</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}
|
||||
@@ -243,7 +297,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-sm text-base-content/60 truncate">
|
||||
{participante.setor || participante.email}
|
||||
{participante.setor || participante.email || ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -254,7 +308,7 @@
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-ghost"
|
||||
onclick={() => rebaixarAdmin(participante._id)}
|
||||
onclick={() => rebaixarAdmin(participanteId)}
|
||||
disabled={isLoading}
|
||||
title="Rebaixar administrador"
|
||||
>
|
||||
@@ -268,7 +322,7 @@
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-ghost"
|
||||
onclick={() => promoverAdmin(participante._id)}
|
||||
onclick={() => promoverAdmin(participanteId)}
|
||||
disabled={isLoading}
|
||||
title="Promover a administrador"
|
||||
>
|
||||
@@ -282,7 +336,7 @@
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-error btn-ghost"
|
||||
onclick={() => removerParticipante(participante._id)}
|
||||
onclick={() => removerParticipante(participanteId)}
|
||||
disabled={isLoading}
|
||||
title="Remover participante"
|
||||
>
|
||||
@@ -316,32 +370,33 @@
|
||||
|
||||
<div class="space-y-2">
|
||||
{#if usuariosFiltrados().length > 0}
|
||||
{#each usuariosFiltrados() as usuario (usuario._id)}
|
||||
{@const isLoading = loading?.includes(usuario._id)}
|
||||
{#each usuariosFiltrados() as usuario (String(usuario._id))}
|
||||
{@const usuarioId = String(usuario._id)}
|
||||
{@const isLoading = loading?.includes(usuarioId)}
|
||||
<button
|
||||
type="button"
|
||||
class="w-full text-left px-4 py-3 rounded-lg border border-base-300 hover:bg-base-200 transition-colors flex items-center gap-3"
|
||||
onclick={() => adicionarParticipante(usuario._id)}
|
||||
onclick={() => adicionarParticipante(usuarioId)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<!-- Avatar -->
|
||||
<div class="relative flex-shrink-0">
|
||||
<UserAvatar
|
||||
avatar={usuario.avatar}
|
||||
fotoPerfilUrl={usuario.fotoPerfilUrl}
|
||||
nome={usuario.nome}
|
||||
fotoPerfilUrl={usuario.fotoPerfilUrl || usuario.fotoPerfil}
|
||||
nome={usuario.nome || "Usuário"}
|
||||
size="sm"
|
||||
/>
|
||||
<div class="absolute bottom-0 right-0">
|
||||
<UserStatusBadge status={usuario.statusPresenca} 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}</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}
|
||||
{usuario.setor || usuario.email || ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
|
||||
import { format } from "date-fns";
|
||||
import { ptBR } from "date-fns/locale";
|
||||
import { Clock, X, Trash2 } from "lucide-svelte";
|
||||
|
||||
interface Props {
|
||||
conversaId: Id<"conversas">;
|
||||
|
||||
Reference in New Issue
Block a user