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:
2025-11-05 14:05:52 -03:00
parent c459297968
commit 6166043735
9 changed files with 394 additions and 104 deletions

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { AlertCircle } from "lucide-svelte";
import { AlertCircle, X } from "lucide-svelte";
interface Props {
open: boolean;
@@ -16,28 +16,67 @@
details,
onClose,
}: Props = $props();
let modalRef: HTMLDialogElement;
function handleClose() {
open = false;
onClose();
}
$effect(() => {
if (open && modalRef) {
modalRef.showModal();
} else if (!open && modalRef) {
modalRef.close();
}
});
</script>
{#if open}
<div class="modal modal-open">
<div class="modal-box">
<h3 class="font-bold text-lg text-error mb-4 flex items-center gap-2">
<AlertCircle class="h-6 w-6" strokeWidth={2} />
{title}
</h3>
<p class="py-4 text-base-content">{message}</p>
{#if details}
<div class="bg-base-200 rounded-lg p-3 mb-4">
<p class="text-sm text-base-content/70 whitespace-pre-line">{details}</p>
</div>
{/if}
<div class="modal-action">
<button class="btn btn-primary" onclick={() => { open = false; onClose(); }}>
<dialog
bind:this={modalRef}
class="modal"
onclick={(e) => e.target === e.currentTarget && handleClose()}
>
<div class="modal-box max-w-2xl" onclick={(e) => e.stopPropagation()}>
<!-- Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-base-300">
<h2 id="modal-title" class="text-xl font-bold flex items-center gap-2 text-error">
<AlertCircle class="w-5 h-5" strokeWidth={2} />
{title}
</h2>
<button
type="button"
class="btn btn-ghost btn-sm btn-circle"
onclick={handleClose}
aria-label="Fechar"
>
<X class="w-5 h-5" />
</button>
</div>
<!-- Content -->
<div class="px-6 py-6">
<p class="text-base-content mb-4">{message}</p>
{#if details}
<div class="bg-base-200 rounded-lg p-4 mb-4">
<p class="text-sm text-base-content/70 whitespace-pre-line">{details}</p>
</div>
{/if}
</div>
<!-- Footer -->
<div class="modal-action px-6 pb-6">
<button class="btn btn-primary" onclick={handleClose}>
Fechar
</button>
</div>
</div>
<div class="modal-backdrop" onclick={() => { open = false; onClose(); }}></div>
</div>
<form method="dialog" class="modal-backdrop">
<button type="button" onclick={handleClose}>fechar</button>
</form>
</dialog>
{/if}

View File

@@ -158,7 +158,6 @@
<div class="wizard-ausencia">
<!-- Header -->
<div class="mb-6">
<h2 class="text-3xl font-bold text-primary mb-2">Nova Solicitação de Ausência</h2>
<p class="text-base-content/70">Solicite uma ausência para assuntos particulares</p>
</div>

View File

@@ -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}

View File

@@ -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}

View File

@@ -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>

View File

@@ -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">;