diff --git a/apps/web/src/lib/components/ErrorModal.svelte b/apps/web/src/lib/components/ErrorModal.svelte index 410886c..ba035d8 100644 --- a/apps/web/src/lib/components/ErrorModal.svelte +++ b/apps/web/src/lib/components/ErrorModal.svelte @@ -1,5 +1,5 @@ {#if open} - + + + {/if} diff --git a/apps/web/src/lib/components/ausencias/WizardSolicitacaoAusencia.svelte b/apps/web/src/lib/components/ausencias/WizardSolicitacaoAusencia.svelte index 518a403..319c97f 100644 --- a/apps/web/src/lib/components/ausencias/WizardSolicitacaoAusencia.svelte +++ b/apps/web/src/lib/components/ausencias/WizardSolicitacaoAusencia.svelte @@ -158,7 +158,6 @@
-

Nova Solicitação de Ausência

Solicite uma ausência para assuntos particulares

diff --git a/apps/web/src/lib/components/chat/ChatWindow.svelte b/apps/web/src/lib/components/chat/ChatWindow.svelte index 39fe45a..63115d9 100644 --- a/apps/web/src/lib/components/chat/ChatWindow.svelte +++ b/apps/web/src/lib/components/chat/ChatWindow.svelte @@ -168,22 +168,27 @@ {conversa()?.participantesInfo?.length || 0} {conversa()?.participantesInfo?.length === 1 ? "participante" : "participantes"}

{#if conversa()?.participantesInfo && conversa()?.participantesInfo.length > 0} -
- {#each conversa()?.participantesInfo.slice(0, 5) as participante (participante._id)} -
- {#if participante.fotoPerfilUrl} - {participante.nome} - {:else if participante.avatar} - {participante.nome} - {:else} - {participante.nome} - {/if} -
- {/each} - {#if conversa()?.participantesInfo.length > 5} -
- +{conversa()?.participantesInfo.length - 5} -
+
+
+ {#each conversa()?.participantesInfo.slice(0, 5) as participante (participante._id)} +
+ {#if participante.fotoPerfilUrl} + {participante.nome} + {:else if participante.avatar} + {participante.nome} + {:else} + {participante.nome} + {/if} +
+ {/each} + {#if conversa()?.participantesInfo.length > 5} +
+ +{conversa()?.participantesInfo.length - 5} +
+ {/if} +
+ {#if conversa()?.tipo === "sala_reuniao" && isAdmin?.data} + • Admin {/if}
{/if} diff --git a/apps/web/src/lib/components/chat/MessageList.svelte b/apps/web/src/lib/components/chat/MessageList.svelte index 44b056b..84e7d8c 100644 --- a/apps/web/src/lib/components/chat/MessageList.svelte +++ b/apps/web/src/lib/components/chat/MessageList.svelte @@ -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 { @@ -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; + }
{formatarDataMensagem(mensagem.enviadaEm)}

+ {#if isMinha && !mensagem.deletada && !mensagem.agendadaPara} + +
+ {#if mensagemFoiLida(mensagem)} + + + + + + + + {:else} + + + + + {/if} +
+ {/if} {#if !mensagem.deletada && !mensagem.agendadaPara}
{#if isMinha} diff --git a/apps/web/src/lib/components/chat/SalaReuniaoManager.svelte b/apps/web/src/lib/components/chat/SalaReuniaoManager.svelte index 56b776f..b46b875 100644 --- a/apps/web/src/lib/components/chat/SalaReuniaoManager.svelte +++ b/apps/web/src/lib/components/chat/SalaReuniaoManager.svelte @@ -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 @@
- {#if activeTab === "participantes"} + {#if !conversas?.data} + +
+ + Carregando conversa... +
+ {:else if !todosUsuariosQuery?.data} + +
+ + Carregando usuários... +
+ {:else if activeTab === "participantes"}
{#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)}
@@ -222,19 +276,19 @@
- +
-

{participante.nome}

+

{participante.nome || "Usuário"}

{#if ehAdmin} Admin {/if} @@ -243,7 +297,7 @@ {/if}

- {participante.setor || participante.email} + {participante.setor || participante.email || ""}

@@ -254,7 +308,7 @@ +
+ + +
{ @@ -2242,9 +2260,10 @@ />
- - - + + {/if} diff --git a/packages/backend/convex/chat.ts b/packages/backend/convex/chat.ts index 127b3bf..2ef4646 100644 --- a/packages/backend/convex/chat.ts +++ b/packages/backend/convex/chat.ts @@ -346,6 +346,7 @@ export const enviarMensagem = mutation({ mencoes: args.mencoes, respostaPara: args.respostaPara, enviadaEm: Date.now(), + lidaPor: [], // Inicializar como array vazio }); // Detectar URLs no conteúdo e extrair preview (apenas para mensagens de texto, assíncrono) @@ -495,14 +496,19 @@ export const agendarMensagem = mutation({ throw new Error("Você não pertence a esta conversa"); } + // Normalizar conteúdo para busca + const conteudoBusca = normalizarTextoParaBusca(args.conteudo); + // Criar mensagem agendada const mensagemId = await ctx.db.insert("mensagens", { conversaId: args.conversaId, remetenteId: usuarioAtual._id, tipo: "texto", conteudo: args.conteudo, + conteudoBusca, agendadaPara: args.agendadaPara, - enviadaEm: args.agendadaPara, // Será usada quando a mensagem for enviada + enviadaEm: args.agendadaPara, // Será atualizado quando a mensagem for enviada + lidaPor: [], // Inicializar como array vazio }); return mensagemId; @@ -662,6 +668,29 @@ export const marcarComoLida = mutation({ }); } + // Atualizar status de leitura nas mensagens + // Buscar todas as mensagens até a mensagem atual (incluindo ela) na conversa + const todasMensagens = await ctx.db + .query("mensagens") + .withIndex("by_conversa", (q) => q.eq("conversaId", args.conversaId)) + .filter((q) => + q.and( + q.lte(q.field("enviadaEm"), mensagem.enviadaEm), + q.neq(q.field("remetenteId"), usuarioAtual._id) // Apenas mensagens de outros usuários + ) + ) + .collect(); + + // Atualizar cada mensagem para incluir o usuário atual no array lidaPor (se ainda não estiver) + for (const msg of todasMensagens) { + const lidaPor = msg.lidaPor || []; + if (!lidaPor.includes(usuarioAtual._id)) { + await ctx.db.patch(msg._id, { + lidaPor: [...lidaPor, usuarioAtual._id], + }); + } + } + // Marcar notificações desta conversa como lidas const notificacoes = await ctx.db .query("notificacoes") @@ -1455,16 +1484,33 @@ export const enviarNotificacaoReuniao = mutation({ // Criar notificação para todos os participantes for (const participanteId of conversa.participantes) { + const tituloNotificacao = args.titulo || "Notificação da sala de reunião"; + const descricaoNotificacao = args.mensagem.substring(0, 100); // Limitar descrição para push + + // Criar notificação no banco await ctx.db.insert("notificacoes", { usuarioId: participanteId, tipo: "nova_mensagem", conversaId: args.conversaId, remetenteId: usuarioAtual._id, - titulo: args.titulo || "Notificação da sala de reunião", + titulo: tituloNotificacao, descricao: args.mensagem, lida: false, criadaEm: Date.now(), }); + + // Enviar push notification (assíncrono, não bloqueia) + ctx.scheduler.runAfter(0, internal.pushNotifications.enviarPushNotification, { + usuarioId: participanteId, + titulo: tituloNotificacao, + corpo: descricaoNotificacao, + data: { + conversaId: args.conversaId, + tipo: "notificacao_reuniao", + }, + }).catch((error) => { + console.error(`Erro ao agendar push para usuário ${participanteId}:`, error); + }); } return { sucesso: true }; @@ -1952,6 +1998,13 @@ export const listarTodosUsuarios = query({ const funcionario = await ctx.db.get(u.funcionarioId); matricula = funcionario?.matricula; } + + // Buscar URL da foto de perfil se existir + let fotoPerfilUrl: string | null = null; + if (u.fotoPerfil) { + fotoPerfilUrl = await ctx.storage.getUrl(u.fotoPerfil); + } + return { _id: u._id, nome: u.nome, @@ -1959,6 +2012,7 @@ export const listarTodosUsuarios = query({ matricula, avatar: u.avatar, fotoPerfil: u.fotoPerfil, + fotoPerfilUrl, statusPresenca: u.statusPresenca, statusMensagem: u.statusMensagem, setor: u.setor, @@ -2246,22 +2300,23 @@ export const enviarMensagensAgendadas = internalMutation({ const agora = Date.now(); // Buscar mensagens que deveriam ser enviadas + // Como o índice by_agendamento indexa por agendadaPara, podemos usar range query + // Buscar mensagens com agendadaPara entre 0 e agora (mensagens agendadas que já devem ser enviadas) + // Valores undefined não aparecem no índice, então só buscamos mensagens realmente agendadas const mensagensAgendadas = await ctx.db .query("mensagens") - .withIndex("by_agendamento") - .filter((q) => - q.and( - q.neq(q.field("agendadaPara"), undefined), - q.lte(q.field("agendadaPara"), agora) - ) - ) + .withIndex("by_agendamento", (q) => q.gte("agendadaPara", 0).lte("agendadaPara", agora)) .collect(); for (const mensagem of mensagensAgendadas) { + // Normalizar conteúdo para busca (se ainda não foi feito) + const conteudoBusca = mensagem.conteudoBusca || normalizarTextoParaBusca(mensagem.conteudo); + // Atualizar mensagem para "enviada" await ctx.db.patch(mensagem._id, { agendadaPara: undefined, enviadaEm: agora, + conteudoBusca: conteudoBusca, // Garantir que tem conteúdo de busca }); // Atualizar última mensagem da conversa @@ -2275,19 +2330,43 @@ export const enviarMensagensAgendadas = internalMutation({ // Criar notificações para outros participantes const remetente = await ctx.db.get(mensagem.remetenteId); - for (const participanteId of conversa.participantes) { - if (participanteId !== mensagem.remetenteId) { - await ctx.db.insert("notificacoes", { - usuarioId: participanteId, - tipo: "nova_mensagem", - conversaId: mensagem.conversaId, - mensagemId: mensagem._id, - remetenteId: mensagem.remetenteId, - titulo: `Nova mensagem de ${remetente?.nome || "Usuário"}`, - descricao: mensagem.conteudo.substring(0, 100), - lida: false, - criadaEm: agora, - }); + if (remetente) { + // Determinar tipo de notificação (se há menções) + const tipoNotificacao = mensagem.mencoes && mensagem.mencoes.length > 0 ? "mencao" : "nova_mensagem"; + const titulo = tipoNotificacao === "mencao" + ? `${remetente.nome} mencionou você` + : `Nova mensagem de ${remetente.nome}`; + const descricao = mensagem.conteudo.substring(0, 100); + + for (const participanteId of conversa.participantes) { + if (participanteId !== mensagem.remetenteId) { + // Criar notificação no banco + await ctx.db.insert("notificacoes", { + usuarioId: participanteId, + tipo: tipoNotificacao, + conversaId: mensagem.conversaId, + mensagemId: mensagem._id, + remetenteId: mensagem.remetenteId, + titulo, + descricao, + lida: false, + criadaEm: agora, + }); + + // Enviar push notification (assíncrono, não bloqueia) + ctx.scheduler.runAfter(0, internal.pushNotifications.enviarPushNotification, { + usuarioId: participanteId, + titulo, + corpo: descricao, + data: { + conversaId: mensagem.conversaId, + mensagemId: mensagem._id, + tipo: tipoNotificacao, + }, + }).catch((error) => { + console.error(`Erro ao agendar push para usuário ${participanteId}:`, error); + }); + } } } } diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index 7a3fb75..c5b501e 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -671,6 +671,7 @@ export default defineSchema({ enviadaEm: v.number(), editadaEm: v.optional(v.number()), deletada: v.optional(v.boolean()), + lidaPor: v.optional(v.array(v.id("usuarios"))), // IDs dos usuários que leram a mensagem }) .index("by_conversa", ["conversaId", "enviadaEm"]) .index("by_remetente", ["remetenteId"])