From 1774b135b3e2f90a3b282d2e9ba1b9f786144525 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Wed, 5 Nov 2025 10:40:30 -0300 Subject: [PATCH 1/6] feat: enhance chat functionality with global notifications and user management - Implemented global notifications for new messages, allowing users to receive alerts even when the chat is minimized or closed. - Added functionality for users to leave group conversations and meeting rooms, with appropriate notifications sent to remaining participants. - Introduced a modal for sending notifications within meeting rooms, enabling admins to communicate important messages to all participants. - Enhanced the chat components to support mention functionality, allowing users to tag participants in messages for better engagement. - Updated backend mutations to handle user exit from conversations and sending notifications, ensuring robust data handling and user experience. --- .../src/lib/components/chat/ChatWidget.svelte | 171 ++++++++++++ .../src/lib/components/chat/ChatWindow.svelte | 251 +++++++++++++++++- .../lib/components/chat/MessageInput.svelte | 130 ++++++++- .../lib/components/chat/MessageList.svelte | 143 ++++++++++ .../components/chat/SalaReuniaoManager.svelte | 6 +- packages/backend/convex/chat.ts | 170 ++++++++++++ 6 files changed, 853 insertions(+), 18 deletions(-) diff --git a/apps/web/src/lib/components/chat/ChatWidget.svelte b/apps/web/src/lib/components/chat/ChatWidget.svelte index f014d77..325db0c 100644 --- a/apps/web/src/lib/components/chat/ChatWidget.svelte +++ b/apps/web/src/lib/components/chat/ChatWidget.svelte @@ -7,6 +7,7 @@ minimizarChat, maximizarChat, abrirChat, + abrirConversa, } from "$lib/stores/chatStore"; import { useQuery } from "convex-svelte"; import { api } from "@sgse-app/backend/convex/_generated/api"; @@ -70,6 +71,9 @@ } let windowSize = $state(getSavedSize()); + let isMaximized = $state(false); + let previousSize = $state({ width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT }); + let previousPosition = $state({ x: 0, y: 0 }); // Salvar tamanho no localStorage function saveSize() { @@ -152,6 +156,94 @@ activeConversation = $conversaAtiva; }); + // Detectar novas mensagens globalmente (mesmo quando chat está fechado/minimizado) + const todasConversas = useQuery(api.chat.listarConversas, {}); + let ultimoTimestampGlobal = $state(0); + let showGlobalNotificationPopup = $state(false); + let globalNotificationMessage = $state<{ remetente: string; conteudo: string; conversaId: string } | null>(null); + let globalNotificationTimeout: ReturnType | null = null; + + // Função para tocar som de notificação + function tocarSomNotificacaoGlobal() { + try { + const AudioContext = window.AudioContext || (window as any).webkitAudioContext; + const audioContext = new AudioContext(); + 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(() => {}); + } else { + 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 (e) { + // Ignorar erro de áudio + } + } + + $effect(() => { + if (todasConversas?.data && authStore.usuario?._id) { + // Encontrar o maior timestamp de todas as conversas + let maiorTimestamp = 0; + let conversaComNovaMensagem: any = null; + + todasConversas.data.forEach((conv: any) => { + if (conv.ultimaMensagemTimestamp && conv.ultimaMensagemTimestamp > maiorTimestamp) { + maiorTimestamp = conv.ultimaMensagemTimestamp; + conversaComNovaMensagem = conv; + } + }); + + // Se há nova mensagem (timestamp maior) e não estamos vendo essa conversa, tocar som e mostrar popup + if (maiorTimestamp > ultimoTimestampGlobal && conversaComNovaMensagem) { + const conversaAtivaId = activeConversation ? String(activeConversation) : null; + const conversaIdStr = String(conversaComNovaMensagem._id); + + // Só mostrar notificação se não estamos vendo essa conversa + if (!isOpen || conversaAtivaId !== conversaIdStr) { + // Tocar som de notificação + tocarSomNotificacaoGlobal(); + + // Mostrar popup de notificação + globalNotificationMessage = { + remetente: conversaComNovaMensagem.outroUsuario?.nome || conversaComNovaMensagem.nome || "Usuário", + conteudo: conversaComNovaMensagem.ultimaMensagem || "", + conversaId: conversaComNovaMensagem._id + }; + showGlobalNotificationPopup = true; + + // Ocultar popup após 5 segundos + if (globalNotificationTimeout) { + clearTimeout(globalNotificationTimeout); + } + globalNotificationTimeout = setTimeout(() => { + showGlobalNotificationPopup = false; + globalNotificationMessage = null; + }, 5000); + } + + ultimoTimestampGlobal = maiorTimestamp; + } + } + }); + function handleToggle() { if (isOpen && !isMinimized) { minimizarChat(); @@ -169,6 +261,31 @@ } function handleMaximize() { + if (isMaximized) { + // Restaurar tamanho anterior + windowSize = previousSize; + position = previousPosition; + isMaximized = false; + saveSize(); + ajustarPosicao(); + } else { + // Salvar tamanho e posição atuais + previousSize = { ...windowSize }; + previousPosition = { ...position }; + + // Maximizar completamente: usar toda a largura e altura da tela + windowSize = { + width: window.innerWidth, + height: window.innerHeight + }; + position = { + x: 0, + y: 0 + }; + isMaximized = true; + saveSize(); + ajustarPosicao(); + } maximizarChat(); } @@ -546,6 +663,60 @@ {/if} + +{#if showGlobalNotificationPopup && globalNotificationMessage} +
{ + showGlobalNotificationPopup = false; + globalNotificationMessage = null; + if (globalNotificationTimeout) { + clearTimeout(globalNotificationTimeout); + } + // Abrir chat e conversa ao clicar + abrirChat(); + abrirConversa(globalNotificationMessage.conversaId as any); + }} + > +
+
+ + + +
+
+

Nova mensagem de {globalNotificationMessage.remetente}

+

{globalNotificationMessage.conteudo}

+

Clique para abrir

+
+ +
+
+{/if} + + + diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index e13ff68..e43a543 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -4,4 +4,7 @@ import { defineConfig } from "vite"; export default defineConfig({ plugins: [tailwindcss(), sveltekit()], + resolve: { + dedupe: ["lucide-svelte"], + }, }); diff --git a/bun.lock b/bun.lock index 16a9a59..fca6f51 100644 --- a/bun.lock +++ b/bun.lock @@ -6,7 +6,7 @@ "dependencies": { "@tanstack/svelte-form": "^1.23.8", "chart.js": "^4.5.1", - "lucide-svelte": "^0.548.0", + "lucide-svelte": "^0.552.0", "svelte-chartjs": "^3.1.5", "svelte-sonner": "^1.0.5", }, @@ -61,7 +61,7 @@ "version": "1.0.0", "dependencies": { "@dicebear/avataaars": "^9.2.4", - "convex": "^1.17.4", + "convex": "catalog:", "nodemailer": "^7.0.10", }, "devDependencies": { @@ -624,7 +624,7 @@ "locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="], - "lucide-svelte": ["lucide-svelte@0.548.0", "", { "peerDependencies": { "svelte": "^3 || ^4 || ^5.0.0-next.42" } }, "sha512-aW2BfHWBLWf/XPSKytTPV16AWfFeFIJeUyOg7eHY2rhzVQ0u0LIvoS4pm2oskr+OJVw+NsS8fPvlBVqPfUO1XQ=="], + "lucide-svelte": ["lucide-svelte@0.552.0", "", { "peerDependencies": { "svelte": "^3 || ^4 || ^5.0.0-next.42" } }, "sha512-zynJ64KOsuQG3I4tSqfvvl7Kc9x4mWkppbxsuyrbegQwma9HFhBp4aE6HuQNF4c3pS0AHWHki5CAMs5m3QXA5w=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], diff --git a/package.json b/package.json index f8b266d..9088df1 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "dependencies": { "@tanstack/svelte-form": "^1.23.8", "chart.js": "^4.5.1", - "lucide-svelte": "^0.548.0", + "lucide-svelte": "^0.552.0", "svelte-chartjs": "^3.1.5", "svelte-sonner": "^1.0.5" }, diff --git a/packages/backend/convex/chat.ts b/packages/backend/convex/chat.ts index 28978fe..127b3bf 100644 --- a/packages/backend/convex/chat.ts +++ b/packages/backend/convex/chat.ts @@ -369,6 +369,7 @@ export const enviarMensagem = mutation({ await ctx.db.patch(args.conversaId, { ultimaMensagem: args.conteudo.substring(0, 100), ultimaMensagemTimestamp: Date.now(), + ultimaMensagemRemetenteId: usuarioAtual._id, // Guardar ID do remetente da última mensagem }); // Criar notificações para participantes (com tratamento de erro) @@ -1815,10 +1816,10 @@ export const listarAgendamentosChat = query({ return mensagensEnriquecidas .filter((m): m is NonNullable => m !== null) .sort((a, b) => { - const dataA = a.agendadaPara ?? 0; - const dataB = b.agendadaPara ?? 0; - return dataA - dataB; - }); + const dataA = a.agendadaPara ?? 0; + const dataB = b.agendadaPara ?? 0; + return dataA - dataB; + }); }, }); @@ -2071,7 +2072,7 @@ export const buscarMensagens = query({ // Filtrar por remetente (já verificado acima, mas garantir novamente) if (args.remetenteId) { if (m.remetenteId !== args.remetenteId) { - return false; + return false; } // Verificar novamente se o remetente é participante da conversa específica desta mensagem if (!conversaDaMensagem.participantes.includes(args.remetenteId)) { @@ -2269,6 +2270,7 @@ export const enviarMensagensAgendadas = internalMutation({ await ctx.db.patch(mensagem.conversaId, { ultimaMensagem: mensagem.conteudo.substring(0, 100), ultimaMensagemTimestamp: agora, + ultimaMensagemRemetenteId: mensagem.remetenteId, // Guardar ID do remetente }); // Criar notificações para outros participantes diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index 28bfdae..7a3fb75 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -626,6 +626,7 @@ export default defineSchema({ administradores: v.optional(v.array(v.id("usuarios"))), // IDs dos administradores (apenas para sala_reuniao) ultimaMensagem: v.optional(v.string()), ultimaMensagemTimestamp: v.optional(v.number()), + ultimaMensagemRemetenteId: v.optional(v.id("usuarios")), // ID do remetente da última mensagem criadoPor: v.id("usuarios"), criadoEm: v.number(), }) -- 2.49.1 From c4592979685ccb5f51648803c16c12198462714b Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Wed, 5 Nov 2025 12:09:41 -0300 Subject: [PATCH 3/6] refactor: update components to use lucide icons and improve structure - Replaced SVG icons with lucide-svelte components across various files for consistency and improved performance. - Refactored ActionGuard, ErrorModal, FileUpload, and other components to enhance readability and maintainability. - Updated the dashboard pages to include new icons and improved layout for better user experience. - Enhanced StatsCard component to support dynamic icon rendering, allowing for more flexible usage. - Improved overall styling and structure in multiple components to align with design standards. --- .../web/src/lib/components/ActionGuard.svelte | 154 +-- apps/web/src/lib/components/ErrorModal.svelte | 17 +- apps/web/src/lib/components/FileUpload.svelte | 30 +- .../lib/components/ModelosDeclaracoes.svelte | 321 +++-- apps/web/src/lib/components/PrintModal.svelte | 921 +++++++------ apps/web/src/lib/components/Sidebar.svelte | 94 +- .../src/lib/components/chat/ChatWindow.svelte | 75 +- .../lib/components/chat/MessageInput.svelte | 41 +- .../components/chat/NotificationBell.svelte | 105 +- .../src/lib/components/ti/StatsCard.svelte | 81 +- apps/web/src/routes/(dashboard)/+page.svelte | 1161 ++++++++--------- .../routes/(dashboard)/compras/+page.svelte | 91 +- .../(dashboard)/comunicacao/+page.svelte | 91 +- .../(dashboard)/controladoria/+page.svelte | 187 ++- .../(dashboard)/financeiro/+page.svelte | 187 ++- .../routes/(dashboard)/juridico/+page.svelte | 91 +- .../(dashboard)/licitacoes/+page.svelte | 187 ++- .../programas-esportivos/+page.svelte | 91 +- .../(dashboard)/recursos-humanos/+page.svelte | 534 ++++---- .../ti/painel-administrativo/+page.svelte | 29 +- .../routes/(dashboard)/ti/perfis/+page.svelte | 11 +- .../ti/solicitacoes-acesso/+page.svelte | 9 +- 22 files changed, 2085 insertions(+), 2423 deletions(-) diff --git a/apps/web/src/lib/components/ActionGuard.svelte b/apps/web/src/lib/components/ActionGuard.svelte index 90cbe93..d88ca22 100644 --- a/apps/web/src/lib/components/ActionGuard.svelte +++ b/apps/web/src/lib/components/ActionGuard.svelte @@ -1,83 +1,71 @@ - - -{#if verificando} -
-
- -

Verificando permissões...

-
-
-{:else if permitido} - {@render children?.()} -{:else} -
-
-
- - - -
-

Acesso Negado

-

- Você não tem permissão para acessar esta ação. -

-
-
-{/if} + + +{#if verificando} +
+
+ +

Verificando permissões...

+
+
+{:else if permitido} + {@render children?.()} +{:else} +
+
+
+ +
+

Acesso Negado

+

+ Você não tem permissão para acessar esta ação. +

+
+
+{/if} diff --git a/apps/web/src/lib/components/ErrorModal.svelte b/apps/web/src/lib/components/ErrorModal.svelte index e5b1ca0..410886c 100644 --- a/apps/web/src/lib/components/ErrorModal.svelte +++ b/apps/web/src/lib/components/ErrorModal.svelte @@ -1,4 +1,6 @@ - -
-
-

- - - - Modelos de Declarações -

- -
- - - -
-

Baixe os modelos, preencha, assine e faça upload no sistema

-

Estes documentos são necessários para completar o cadastro do funcionário

-
-
- -
- {#each modelosDeclaracoes as modelo} -
-
-
- -
- - - -
- - -
-

{modelo.nome}

-

{modelo.descricao}

- - -
- - - {#if showPreencherButton && modelo.podePreencherAutomaticamente && funcionario} - - {/if} -
-
-
-
-
- {/each} -
- -
-

💡 Dica: Após preencher e assinar os documentos, faça upload na seção "Documentação Anexa"

-
-
-
- + + +
+
+

+ + Modelos de Declarações +

+ +
+ +
+

Baixe os modelos, preencha, assine e faça upload no sistema

+

Estes documentos são necessários para completar o cadastro do funcionário

+
+
+ +
+ {#each modelosDeclaracoes as modelo} +
+
+
+ +
+ + + +
+ + +
+

{modelo.nome}

+

{modelo.descricao}

+ + +
+ + + {#if showPreencherButton && modelo.podePreencherAutomaticamente && funcionario} + + {/if} +
+
+
+
+
+ {/each} +
+ +
+

💡 Dica: Após preencher e assinar os documentos, faça upload na seção "Documentação Anexa"

+
+
+
+ diff --git a/apps/web/src/lib/components/PrintModal.svelte b/apps/web/src/lib/components/PrintModal.svelte index d411b57..566f550 100644 --- a/apps/web/src/lib/components/PrintModal.svelte +++ b/apps/web/src/lib/components/PrintModal.svelte @@ -1,463 +1,458 @@ - - - - - - - - + + + + + + + + diff --git a/apps/web/src/lib/components/Sidebar.svelte b/apps/web/src/lib/components/Sidebar.svelte index 0df953f..cc0b25b 100644 --- a/apps/web/src/lib/components/Sidebar.svelte +++ b/apps/web/src/lib/components/Sidebar.svelte @@ -12,6 +12,7 @@ import PresenceManager from "$lib/components/chat/PresenceManager.svelte"; import { getBrowserInfo } from "$lib/utils/browserInfo"; import { getAvatarUrl } from "$lib/utils/avatarGenerator"; + import { Menu, User, Home, UserPlus, XCircle, LogIn, Tag, Plus, Check } from "lucide-svelte"; let { children }: { children: Snippet } = $props(); @@ -180,21 +181,11 @@
- - - + strokeWidth={2.5} + />
@@ -261,15 +252,10 @@ /> {:else} - - - + /> {/if} @@ -301,21 +287,11 @@
- - - + strokeWidth={2.5} + /> {/if}
@@ -365,20 +341,10 @@ href="/" class={getMenuClasses(currentPath === "/")} > - - - + strokeWidth={2} + /> Dashboard @@ -399,20 +365,10 @@ href="/solicitar-acesso" class={getSolicitarClasses(currentPath === "/solicitar-acesso")} > - - - + strokeWidth={2} + /> Solicitar acesso @@ -446,9 +402,7 @@ {#if erroLogin}
- - - + {erroLogin}
{/if} @@ -492,9 +446,7 @@ Entrando... {:else} - - - + Entrar {/if} @@ -559,16 +511,12 @@
- - - +

Versão

1.0 26_2025

- - - + Em Desenvolvimento
@@ -603,9 +551,7 @@ class="btn btn-primary btn-lg w-full max-w-xs mx-auto shadow-lg hover:shadow-xl transition-all duration-300" onclick={closeAboutModal} > - - - + OK diff --git a/apps/web/src/lib/components/chat/ChatWindow.svelte b/apps/web/src/lib/components/chat/ChatWindow.svelte index 14636f6..39fe45a 100644 --- a/apps/web/src/lib/components/chat/ChatWindow.svelte +++ b/apps/web/src/lib/components/chat/ChatWindow.svelte @@ -11,7 +11,7 @@ import SalaReuniaoManager from "./SalaReuniaoManager.svelte"; import { getAvatarUrl } from "$lib/utils/avatarGenerator"; import { authStore } from "$lib/stores/auth.svelte"; - import { Bell, X } from "lucide-svelte"; + import { Bell, X, ArrowLeft, LogOut, MoreVertical, Users, Clock, XCircle } from "lucide-svelte"; interface Props { conversaId: string; @@ -117,18 +117,10 @@ aria-label="Voltar" title="Voltar para lista de conversas" > - - - + strokeWidth={2.5} + /> @@ -215,20 +207,10 @@ title="Sair da conversa" >
- - - - - + strokeWidth={2} + /> {/if} @@ -247,20 +229,10 @@ title="Recursos administrativos" >
- - - - - + strokeWidth={2} + /> {#if showAdminMenu}
    - - - + Gerenciar Participantes @@ -293,9 +263,7 @@ showAdminMenu = false; }} > - - - + Enviar Notificação @@ -324,9 +292,7 @@ })(); }} > - - - + Encerrar Reunião @@ -345,19 +311,10 @@ title="Agendar mensagem" >
    - - - - + strokeWidth={2} + /> diff --git a/apps/web/src/lib/components/chat/MessageInput.svelte b/apps/web/src/lib/components/chat/MessageInput.svelte index 4878c6d..8f35137 100644 --- a/apps/web/src/lib/components/chat/MessageInput.svelte +++ b/apps/web/src/lib/components/chat/MessageInput.svelte @@ -4,6 +4,7 @@ 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 { conversaId: Id<"conversas">; @@ -340,18 +341,10 @@ {:else} - - - + strokeWidth={2} + /> {/if} @@ -367,21 +360,10 @@ title="Adicionar emoji" >
    - - - - - - + strokeWidth={2} + /> @@ -458,14 +440,9 @@ {:else} - - - + /> {/if} diff --git a/apps/web/src/lib/components/chat/NotificationBell.svelte b/apps/web/src/lib/components/chat/NotificationBell.svelte index 047f7da..6bf2489 100644 --- a/apps/web/src/lib/components/chat/NotificationBell.svelte +++ b/apps/web/src/lib/components/chat/NotificationBell.svelte @@ -6,6 +6,7 @@ import { ptBR } from "date-fns/locale"; import { onMount } from "svelte"; import { authStore } from "$lib/stores/auth.svelte"; + import { Bell, Mail, AtSign, Users, Calendar, Clock, BellOff } from "lucide-svelte"; // Queries e Client const client = useConvexClient(); @@ -200,22 +201,14 @@ {/if} - - - + fill="currentColor" + /> {#if count + (notificacoesFerias?.length || 0) > 0} @@ -264,50 +257,11 @@
    {#if notificacao.tipo === "nova_mensagem"} - - - + {:else if notificacao.tipo === "mencao"} - - - + {:else} - - - + {/if}
    @@ -349,20 +303,7 @@
    - - - +
    @@ -398,20 +339,7 @@
    - - - +
    @@ -436,20 +364,7 @@ {#if notificacoes.length === 0 && notificacoesFerias.length === 0 && notificacoesAusencias.length === 0}
    - - - +

    Nenhuma notificação

    {/if} diff --git a/apps/web/src/lib/components/ti/StatsCard.svelte b/apps/web/src/lib/components/ti/StatsCard.svelte index 8e879ae..8d17419 100644 --- a/apps/web/src/lib/components/ti/StatsCard.svelte +++ b/apps/web/src/lib/components/ti/StatsCard.svelte @@ -1,39 +1,42 @@ - - -
    -
    -
    - {#if icon} - - {@html icon} - - {/if} -
    -
    {title}
    -
    {value}
    - {#if description} -
    {description}
    - {/if} - {#if trend} -
    - {trend.isPositive ? '↗︎' : '↘︎'} {Math.abs(trend.value)}% -
    - {/if} -
    -
    - - + + +
    +
    +
    + {#if Icon} + + {:else if icon} + + {@html icon} + + {/if} +
    +
    {title}
    +
    {value}
    + {#if description} +
    {description}
    + {/if} + {#if trend} +
    + {trend.isPositive ? '↗︎' : '↘︎'} {Math.abs(trend.value)}% +
    + {/if} +
    +
    diff --git a/apps/web/src/routes/(dashboard)/+page.svelte b/apps/web/src/routes/(dashboard)/+page.svelte index fe42423..c3eb11c 100644 --- a/apps/web/src/routes/(dashboard)/+page.svelte +++ b/apps/web/src/routes/(dashboard)/+page.svelte @@ -1,582 +1,579 @@ - - -
    - - {#if showAlert} - {@const alertData = getAlertMessage()} -
    -
    - {alertData.icon} -
    -

    {alertData.title}

    -

    {alertData.message}

    - {#if alertType === "access_denied"} - - {/if} -
    - -
    -
    - {/if} - - -
    -
    -
    -

    - {getSaudacao()}! 👋 -

    -

    - Bem-vindo ao Sistema de Gerenciamento da Secretaria de Esportes -

    -

    - {currentTime.toLocaleDateString("pt-BR", { - weekday: "long", - year: "numeric", - month: "long", - day: "numeric", - })} - {" - "} - {currentTime.toLocaleTimeString("pt-BR")} -

    -
    -
    -
    Sistema Online
    -
    Atualizado
    -
    -
    -
    - - - {#if statsQuery.isLoading} -
    - -
    - {:else if statsQuery.data} -
    - -
    -
    -
    -
    -

    Total de Funcionários

    -

    - {formatNumber(statsQuery.data.totalFuncionarios)} -

    -

    - {statsQuery.data.funcionariosAtivos} ativos -

    -
    -
    - {calcPercentage(statsQuery.data.funcionariosAtivos, statsQuery.data.totalFuncionarios)}% -
    -
    -
    -
    - - -
    -
    -
    -
    -

    Solicitações Pendentes

    -

    - {formatNumber(statsQuery.data.solicitacoesPendentes)} -

    -

    - de {statsQuery.data.totalSolicitacoesAcesso} total -

    -
    -
    - - - -
    -
    -
    -
    - - -
    -
    -
    -
    -

    Símbolos Cadastrados

    -

    - {formatNumber(statsQuery.data.totalSimbolos)} -

    -

    - {statsQuery.data.cargoComissionado} CC / {statsQuery.data.funcaoGratificada} FG -

    -
    -
    - - - -
    -
    -
    -
    - - - {#if activityQuery.data} -
    -
    -
    -
    -

    Atividade (24h)

    -

    - {formatNumber(activityQuery.data.funcionariosCadastrados24h + activityQuery.data.solicitacoesAcesso24h)} -

    -

    - {activityQuery.data.funcionariosCadastrados24h} cadastros -

    -
    -
    - - - -
    -
    -
    -
    - {/if} -
    - - - {#if statusSistemaQuery.data && atividadeBDQuery.data && distribuicaoQuery.data} - {@const status = statusSistemaQuery.data} - {@const atividade = atividadeBDQuery.data} - {@const distribuicao = distribuicaoQuery.data} - -
    -
    -
    - - - -
    -
    -

    Monitoramento em Tempo Real

    -

    - Atualizado a cada segundo • {new Date(status.ultimaAtualizacao).toLocaleTimeString('pt-BR')} -

    -
    -
    - - - LIVE -
    -
    - - -
    - -
    -
    -
    -
    -

    Usuários Online

    -

    {status.usuariosOnline}

    -

    sessões ativas

    -
    -
    - - - -
    -
    -
    -
    - - -
    -
    -
    -
    -

    Total Registros

    -

    {status.totalRegistros.toLocaleString('pt-BR')}

    -

    no banco de dados

    -
    -
    - - - -
    -
    -
    -
    - - -
    -
    -
    -
    -

    Tempo Resposta

    -

    {status.tempoMedioResposta}ms

    -

    média atual

    -
    -
    - - - -
    -
    -
    -
    - - -
    -
    -
    -

    Uso do Sistema

    -
    -
    -
    - CPU - {status.cpuUsada}% -
    - -
    -
    -
    - Memória - {status.memoriaUsada}% -
    - -
    -
    -
    -
    -
    -
    - - -
    -
    -
    -
    -

    Atividade do Banco de Dados

    -

    Entradas e saídas em tempo real (último minuto)

    -
    -
    - - Atualizando -
    -
    - -
    - -
    - {#each [10, 8, 6, 4, 2, 0] as val} - {val} - {/each} -
    - - -
    - - {#each Array.from({length: 6}) as _, i} -
    - {/each} - - -
    - {#each atividade.historico as ponto, idx} - {@const maxAtividade = Math.max(...atividade.historico.map(p => Math.max(p.entradas, p.saidas)))} -
    - -
    - -
    - - -
    -
    ↑ {ponto.entradas} entradas
    -
    ↓ {ponto.saidas} saídas
    -
    -
    - {/each} -
    -
    - - -
    - - -
    - -60s - -30s - agora -
    -
    - - -
    -
    -
    - Entradas no BD -
    -
    -
    - Saídas do BD -
    -
    -
    -
    - - -
    -
    -
    -

    Tipos de Operações

    -
    -
    -
    - Queries (Leituras) - {distribuicao.queries} -
    - -
    -
    -
    - Mutations (Escritas) - {distribuicao.mutations} -
    - -
    -
    -
    -
    - -
    -
    -

    Operações no Banco

    -
    -
    -
    - Leituras - {distribuicao.leituras} -
    - -
    -
    -
    - Escritas - {distribuicao.escritas} -
    - -
    -
    -
    -
    -
    -
    - {/if} - - - -
    -
    -
    -

    Status do Sistema

    -
    -
    - Banco de Dados - Online -
    -
    - API - Operacional -
    -
    - Backup - Atualizado -
    -
    -
    -
    - - - -
    -
    -

    Informações

    -
    -

    - Versão: 1.0.0 -

    -

    - Última Atualização: {new Date().toLocaleDateString("pt-BR")} -

    -

    - Suporte: TI SGSE -

    -
    -
    -
    -
    - {/if} -
    - - + + +
    + + {#if showAlert} + {@const alertData = getAlertMessage()} +
    +
    + {alertData.icon} +
    +

    {alertData.title}

    +

    {alertData.message}

    + {#if alertType === "access_denied"} + + {/if} +
    + +
    +
    + {/if} + + +
    +
    +
    +

    + {getSaudacao()}! 👋 +

    +

    + Bem-vindo ao Sistema de Gerenciamento da Secretaria de Esportes +

    +

    + {currentTime.toLocaleDateString("pt-BR", { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + })} + {" - "} + {currentTime.toLocaleTimeString("pt-BR")} +

    +
    +
    +
    Sistema Online
    +
    Atualizado
    +
    +
    +
    + + + {#if statsQuery.isLoading} +
    + +
    + {:else if statsQuery.data} +
    + +
    +
    +
    +
    +

    Total de Funcionários

    +

    + {formatNumber(statsQuery.data.totalFuncionarios)} +

    +

    + {statsQuery.data.funcionariosAtivos} ativos +

    +
    +
    + {calcPercentage(statsQuery.data.funcionariosAtivos, statsQuery.data.totalFuncionarios)}% +
    +
    +
    +
    + + +
    +
    +
    +
    +

    Solicitações Pendentes

    +

    + {formatNumber(statsQuery.data.solicitacoesPendentes)} +

    +

    + de {statsQuery.data.totalSolicitacoesAcesso} total +

    +
    +
    + + + +
    +
    +
    +
    + + +
    +
    +
    +
    +

    Símbolos Cadastrados

    +

    + {formatNumber(statsQuery.data.totalSimbolos)} +

    +

    + {statsQuery.data.cargoComissionado} CC / {statsQuery.data.funcaoGratificada} FG +

    +
    +
    + + + +
    +
    +
    +
    + + + {#if activityQuery.data} +
    +
    +
    +
    +

    Atividade (24h)

    +

    + {formatNumber(activityQuery.data.funcionariosCadastrados24h + activityQuery.data.solicitacoesAcesso24h)} +

    +

    + {activityQuery.data.funcionariosCadastrados24h} cadastros +

    +
    +
    + + + +
    +
    +
    +
    + {/if} +
    + + + {#if statusSistemaQuery.data && atividadeBDQuery.data && distribuicaoQuery.data} + {@const status = statusSistemaQuery.data} + {@const atividade = atividadeBDQuery.data} + {@const distribuicao = distribuicaoQuery.data} + +
    +
    +
    + + + +
    +
    +

    Monitoramento em Tempo Real

    +

    + Atualizado a cada segundo • {new Date(status.ultimaAtualizacao).toLocaleTimeString('pt-BR')} +

    +
    +
    + + + LIVE +
    +
    + + +
    + +
    +
    +
    +
    +

    Usuários Online

    +

    {status.usuariosOnline}

    +

    sessões ativas

    +
    +
    + + + +
    +
    +
    +
    + + +
    +
    +
    +
    +

    Total Registros

    +

    {status.totalRegistros.toLocaleString('pt-BR')}

    +

    no banco de dados

    +
    +
    + + + +
    +
    +
    +
    + + +
    +
    +
    +
    +

    Tempo Resposta

    +

    {status.tempoMedioResposta}ms

    +

    média atual

    +
    +
    + + + +
    +
    +
    +
    + + +
    +
    +
    +

    Uso do Sistema

    +
    +
    +
    + CPU + {status.cpuUsada}% +
    + +
    +
    +
    + Memória + {status.memoriaUsada}% +
    + +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    +

    Atividade do Banco de Dados

    +

    Entradas e saídas em tempo real (último minuto)

    +
    +
    + + Atualizando +
    +
    + +
    + +
    + {#each [10, 8, 6, 4, 2, 0] as val} + {val} + {/each} +
    + + +
    + + {#each Array.from({length: 6}) as _, i} +
    + {/each} + + +
    + {#each atividade.historico as ponto, idx} + {@const maxAtividade = Math.max(...atividade.historico.map(p => Math.max(p.entradas, p.saidas)))} +
    + +
    + +
    + + +
    +
    ↑ {ponto.entradas} entradas
    +
    ↓ {ponto.saidas} saídas
    +
    +
    + {/each} +
    +
    + + +
    + + +
    + -60s + -30s + agora +
    +
    + + +
    +
    +
    + Entradas no BD +
    +
    +
    + Saídas do BD +
    +
    +
    +
    + + +
    +
    +
    +

    Tipos de Operações

    +
    +
    +
    + Queries (Leituras) + {distribuicao.queries} +
    + +
    +
    +
    + Mutations (Escritas) + {distribuicao.mutations} +
    + +
    +
    +
    +
    + +
    +
    +

    Operações no Banco

    +
    +
    +
    + Leituras + {distribuicao.leituras} +
    + +
    +
    +
    + Escritas + {distribuicao.escritas} +
    + +
    +
    +
    +
    +
    +
    + {/if} + + + +
    +
    +
    +

    Status do Sistema

    +
    +
    + Banco de Dados + Online +
    +
    + API + Operacional +
    +
    + Backup + Atualizado +
    +
    +
    +
    + + + +
    +
    +

    Informações

    +
    +

    + Versão: 1.0.0 +

    +

    + Última Atualização: {new Date().toLocaleDateString("pt-BR")} +

    +

    + Suporte: TI SGSE +

    +
    +
    +
    +
    + {/if} +
    + + diff --git a/apps/web/src/routes/(dashboard)/compras/+page.svelte b/apps/web/src/routes/(dashboard)/compras/+page.svelte index d2964d2..619c5c0 100644 --- a/apps/web/src/routes/(dashboard)/compras/+page.svelte +++ b/apps/web/src/routes/(dashboard)/compras/+page.svelte @@ -1,48 +1,43 @@ - - -
    - - -
    -
    -
    - - - -
    -
    -

    Compras

    -

    Gestão de compras e aquisições

    -
    -
    -
    - -
    -
    -
    -
    - - - -
    -

    Módulo em Desenvolvimento

    -

    - O módulo de Compras está sendo desenvolvido e em breve estará disponível com funcionalidades completas para gestão de compras e aquisições. -

    -
    - - - - Em Desenvolvimento -
    -
    -
    -
    -
    - + + +
    + + +
    +
    +
    + +
    +
    +

    Compras

    +

    Gestão de compras e aquisições

    +
    +
    +
    + +
    +
    +
    +
    + +
    +

    Módulo em Desenvolvimento

    +

    + O módulo de Compras está sendo desenvolvido e em breve estará disponível com funcionalidades completas para gestão de compras e aquisições. +

    +
    + + Em Desenvolvimento +
    +
    +
    +
    +
    + diff --git a/apps/web/src/routes/(dashboard)/comunicacao/+page.svelte b/apps/web/src/routes/(dashboard)/comunicacao/+page.svelte index 31165c4..9caada5 100644 --- a/apps/web/src/routes/(dashboard)/comunicacao/+page.svelte +++ b/apps/web/src/routes/(dashboard)/comunicacao/+page.svelte @@ -1,48 +1,43 @@ - - -
    - - -
    -
    -
    - - - -
    -
    -

    Comunicação

    -

    Gestão de comunicação institucional

    -
    -
    -
    - -
    -
    -
    -
    - - - -
    -

    Módulo em Desenvolvimento

    -

    - O módulo de Comunicação está sendo desenvolvido e em breve estará disponível com funcionalidades completas de gestão de comunicação institucional. -

    -
    - - - - Em Desenvolvimento -
    -
    -
    -
    -
    - + + +
    + + +
    +
    +
    + +
    +
    +

    Comunicação

    +

    Gestão de comunicação institucional

    +
    +
    +
    + +
    +
    +
    +
    + +
    +

    Módulo em Desenvolvimento

    +

    + O módulo de Comunicação está sendo desenvolvido e em breve estará disponível com funcionalidades completas de gestão de comunicação institucional. +

    +
    + + Em Desenvolvimento +
    +
    +
    +
    +
    + diff --git a/apps/web/src/routes/(dashboard)/controladoria/+page.svelte b/apps/web/src/routes/(dashboard)/controladoria/+page.svelte index f74e3be..440da96 100644 --- a/apps/web/src/routes/(dashboard)/controladoria/+page.svelte +++ b/apps/web/src/routes/(dashboard)/controladoria/+page.svelte @@ -1,99 +1,88 @@ - - -
    - - - - -
    -
    -
    - - - -
    -
    -

    Controladoria

    -

    Controle e auditoria interna da secretaria

    -
    -
    -
    - - -
    -
    -
    -
    - - - -
    -

    Módulo em Desenvolvimento

    -

    - O módulo de Controladoria está sendo desenvolvido e em breve estará disponível com funcionalidades completas de controle e auditoria. -

    -
    - - - - Em Desenvolvimento -
    -
    -
    -
    - - -
    -

    Funcionalidades Previstas

    -
    -
    -
    -
    -
    - - - -
    -

    Auditoria Interna

    -
    -

    Controle e verificação de processos internos

    -
    -
    - -
    -
    -
    -
    - - - -
    -

    Compliance

    -
    -

    Conformidade com normas e regulamentos

    -
    -
    - -
    -
    -
    -
    - - - -
    -

    Indicadores de Gestão

    -
    -

    Monitoramento de KPIs e métricas

    -
    -
    -
    -
    -
    - + + +
    + + + + +
    +
    +
    + +
    +
    +

    Controladoria

    +

    Controle e auditoria interna da secretaria

    +
    +
    +
    + + +
    +
    +
    +
    + +
    +

    Módulo em Desenvolvimento

    +

    + O módulo de Controladoria está sendo desenvolvido e em breve estará disponível com funcionalidades completas de controle e auditoria. +

    +
    + + Em Desenvolvimento +
    +
    +
    +
    + + +
    +

    Funcionalidades Previstas

    +
    +
    +
    +
    +
    + +
    +

    Auditoria Interna

    +
    +

    Controle e verificação de processos internos

    +
    +
    + +
    +
    +
    +
    + +
    +

    Compliance

    +
    +

    Conformidade com normas e regulamentos

    +
    +
    + +
    +
    +
    +
    + +
    +

    Indicadores de Gestão

    +
    +

    Monitoramento de KPIs e métricas

    +
    +
    +
    +
    +
    + diff --git a/apps/web/src/routes/(dashboard)/financeiro/+page.svelte b/apps/web/src/routes/(dashboard)/financeiro/+page.svelte index 6852168..daf2763 100644 --- a/apps/web/src/routes/(dashboard)/financeiro/+page.svelte +++ b/apps/web/src/routes/(dashboard)/financeiro/+page.svelte @@ -1,99 +1,88 @@ - - -
    - - - - -
    -
    -
    - - - -
    -
    -

    Financeiro

    -

    Gestão financeira e orçamentária da secretaria

    -
    -
    -
    - - -
    -
    -
    -
    - - - -
    -

    Módulo em Desenvolvimento

    -

    - O módulo Financeiro está sendo desenvolvido e em breve estará disponível com funcionalidades completas de gestão financeira e orçamentária. -

    -
    - - - - Em Desenvolvimento -
    -
    -
    -
    - - -
    -

    Funcionalidades Previstas

    -
    -
    -
    -
    -
    - - - -
    -

    Controle Orçamentário

    -
    -

    Gestão e acompanhamento do orçamento anual

    -
    -
    - -
    -
    -
    -
    - - - -
    -

    Fluxo de Caixa

    -
    -

    Controle de entradas e saídas financeiras

    -
    -
    - -
    -
    -
    -
    - - - -
    -

    Relatórios Financeiros

    -
    -

    Geração de relatórios e demonstrativos

    -
    -
    -
    -
    -
    - + + +
    + + + + +
    +
    +
    + +
    +
    +

    Financeiro

    +

    Gestão financeira e orçamentária da secretaria

    +
    +
    +
    + + +
    +
    +
    +
    + +
    +

    Módulo em Desenvolvimento

    +

    + O módulo Financeiro está sendo desenvolvido e em breve estará disponível com funcionalidades completas de gestão financeira e orçamentária. +

    +
    + + Em Desenvolvimento +
    +
    +
    +
    + + +
    +

    Funcionalidades Previstas

    +
    +
    +
    +
    +
    + +
    +

    Controle Orçamentário

    +
    +

    Gestão e acompanhamento do orçamento anual

    +
    +
    + +
    +
    +
    +
    + +
    +

    Fluxo de Caixa

    +
    +

    Controle de entradas e saídas financeiras

    +
    +
    + +
    +
    +
    +
    + +
    +

    Relatórios Financeiros

    +
    +

    Geração de relatórios e demonstrativos

    +
    +
    +
    +
    +
    + diff --git a/apps/web/src/routes/(dashboard)/juridico/+page.svelte b/apps/web/src/routes/(dashboard)/juridico/+page.svelte index 486f80d..b82f26f 100644 --- a/apps/web/src/routes/(dashboard)/juridico/+page.svelte +++ b/apps/web/src/routes/(dashboard)/juridico/+page.svelte @@ -1,48 +1,43 @@ - - -
    - - -
    -
    -
    - - - -
    -
    -

    Jurídico

    -

    Assessoria jurídica e gestão de processos

    -
    -
    -
    - -
    -
    -
    -
    - - - -
    -

    Módulo em Desenvolvimento

    -

    - O módulo Jurídico está sendo desenvolvido e em breve estará disponível com funcionalidades completas de assessoria jurídica e gestão de processos. -

    -
    - - - - Em Desenvolvimento -
    -
    -
    -
    -
    - + + +
    + + +
    +
    +
    + +
    +
    +

    Jurídico

    +

    Assessoria jurídica e gestão de processos

    +
    +
    +
    + +
    +
    +
    +
    + +
    +

    Módulo em Desenvolvimento

    +

    + O módulo Jurídico está sendo desenvolvido e em breve estará disponível com funcionalidades completas de assessoria jurídica e gestão de processos. +

    +
    + + Em Desenvolvimento +
    +
    +
    +
    +
    + diff --git a/apps/web/src/routes/(dashboard)/licitacoes/+page.svelte b/apps/web/src/routes/(dashboard)/licitacoes/+page.svelte index c4255aa..6075af1 100644 --- a/apps/web/src/routes/(dashboard)/licitacoes/+page.svelte +++ b/apps/web/src/routes/(dashboard)/licitacoes/+page.svelte @@ -1,99 +1,88 @@ - - -
    - - - - -
    -
    -
    - - - -
    -
    -

    Licitações

    -

    Gestão de processos licitatórios

    -
    -
    -
    - - -
    -
    -
    -
    - - - -
    -

    Módulo em Desenvolvimento

    -

    - O módulo de Licitações está sendo desenvolvido e em breve estará disponível com funcionalidades completas para gestão de processos licitatórios. -

    -
    - - - - Em Desenvolvimento -
    -
    -
    -
    - - -
    -

    Funcionalidades Previstas

    -
    -
    -
    -
    -
    - - - -
    -

    Processos Licitatórios

    -
    -

    Cadastro e acompanhamento de licitações

    -
    -
    - -
    -
    -
    -
    - - - -
    -

    Fornecedores

    -
    -

    Cadastro e gestão de fornecedores

    -
    -
    - -
    -
    -
    -
    - - - -
    -

    Documentação

    -
    -

    Gestão de documentos e editais

    -
    -
    -
    -
    -
    - + + +
    + + + + +
    +
    +
    + +
    +
    +

    Licitações

    +

    Gestão de processos licitatórios

    +
    +
    +
    + + +
    +
    +
    +
    + +
    +

    Módulo em Desenvolvimento

    +

    + O módulo de Licitações está sendo desenvolvido e em breve estará disponível com funcionalidades completas para gestão de processos licitatórios. +

    +
    + + Em Desenvolvimento +
    +
    +
    +
    + + +
    +

    Funcionalidades Previstas

    +
    +
    +
    +
    +
    + +
    +

    Processos Licitatórios

    +
    +

    Cadastro e acompanhamento de licitações

    +
    +
    + +
    +
    +
    +
    + +
    +

    Fornecedores

    +
    +

    Cadastro e gestão de fornecedores

    +
    +
    + +
    +
    +
    +
    + +
    +

    Documentação

    +
    +

    Gestão de documentos e editais

    +
    +
    +
    +
    +
    + diff --git a/apps/web/src/routes/(dashboard)/programas-esportivos/+page.svelte b/apps/web/src/routes/(dashboard)/programas-esportivos/+page.svelte index 356f023..47809cc 100644 --- a/apps/web/src/routes/(dashboard)/programas-esportivos/+page.svelte +++ b/apps/web/src/routes/(dashboard)/programas-esportivos/+page.svelte @@ -1,48 +1,43 @@ - - -
    - - -
    -
    -
    - - - -
    -
    -

    Programas Esportivos

    -

    Gestão de programas e projetos esportivos

    -
    -
    -
    - -
    -
    -
    -
    - - - -
    -

    Módulo em Desenvolvimento

    -

    - O módulo de Programas Esportivos está sendo desenvolvido e em breve estará disponível com funcionalidades completas para gestão de programas e projetos esportivos. -

    -
    - - - - Em Desenvolvimento -
    -
    -
    -
    -
    - + + +
    + + +
    +
    +
    + +
    +
    +

    Programas Esportivos

    +

    Gestão de programas e projetos esportivos

    +
    +
    +
    + +
    +
    +
    +
    + +
    +

    Módulo em Desenvolvimento

    +

    + O módulo de Programas Esportivos está sendo desenvolvido e em breve estará disponível com funcionalidades completas para gestão de programas e projetos esportivos. +

    +
    + + Em Desenvolvimento +
    +
    +
    +
    +
    + diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/+page.svelte index 90cf160..f3e9bec 100644 --- a/apps/web/src/routes/(dashboard)/recursos-humanos/+page.svelte +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/+page.svelte @@ -1,281 +1,253 @@ - - -
    - -
    -

    Recursos Humanos

    -

    - Gerencie funcionários, símbolos e visualize relatórios do departamento -

    -
    - - - {#if statsQuery.data} -
    -
    -
    -
    - - - -
    -
    Total
    -
    {statsQuery.data.totalFuncionarios}
    -
    Funcionários cadastrados
    -
    -
    - -
    -
    -
    - - - -
    -
    Ativos
    -
    {statsQuery.data.funcionariosAtivos}
    -
    Funcionários ativos
    -
    -
    - -
    -
    -
    - - - -
    -
    Símbolos
    -
    {statsQuery.data.totalSimbolos}
    -
    Cargos e funções
    -
    -
    - -
    -
    -
    - - - -
    -
    CC / FG
    -
    {statsQuery.data.cargoComissionado} / {statsQuery.data.funcaoGratificada}
    -
    Distribuição
    -
    -
    -
    - {/if} - - -
    - {#each menuItems as categoria} -
    -
    - -
    -
    -
    - {@html categoria.icon} -
    -
    -
    -

    - {categoria.categoria} -

    -

    {categoria.descricao}

    -
    -
    - - - -
    -
    - {/each} -
    - - -
    - - - -
    -

    Precisa de ajuda?

    -
    - Entre em contato com o suporte técnico ou consulte a documentação do sistema para mais informações sobre as funcionalidades de Recursos Humanos. -
    -
    -
    -
    - - + + +
    + +
    +

    Recursos Humanos

    +

    + Gerencie funcionários, símbolos e visualize relatórios do departamento +

    +
    + + + {#if statsQuery.data} +
    +
    +
    +
    + +
    +
    Total
    +
    {statsQuery.data.totalFuncionarios}
    +
    Funcionários cadastrados
    +
    +
    + +
    +
    +
    + +
    +
    Ativos
    +
    {statsQuery.data.funcionariosAtivos}
    +
    Funcionários ativos
    +
    +
    + +
    +
    +
    + +
    +
    Símbolos
    +
    {statsQuery.data.totalSimbolos}
    +
    Cargos e funções
    +
    +
    + +
    +
    +
    + +
    +
    CC / FG
    +
    {statsQuery.data.cargoComissionado} / {statsQuery.data.funcaoGratificada}
    +
    Distribuição
    +
    +
    +
    + {/if} + + +
    + {#each menuItems as categoria} +
    +
    + +
    +
    + +
    +
    +

    + {categoria.categoria} +

    +

    {categoria.descricao}

    +
    +
    + + +
    + {#each categoria.opcoes as opcao} + +
    +
    +
    + +
    + +
    +

    + {opcao.nome} +

    +

    + {opcao.descricao} +

    +
    +
    + {/each} +
    +
    +
    + {/each} +
    + + +
    + +
    +

    Precisa de ajuda?

    +
    + Entre em contato com o suporte técnico ou consulte a documentação do sistema para mais informações sobre as funcionalidades de Recursos Humanos. +
    +
    +
    +
    + + diff --git a/apps/web/src/routes/(dashboard)/ti/painel-administrativo/+page.svelte b/apps/web/src/routes/(dashboard)/ti/painel-administrativo/+page.svelte index 0349c24..d005651 100644 --- a/apps/web/src/routes/(dashboard)/ti/painel-administrativo/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/painel-administrativo/+page.svelte @@ -2,6 +2,7 @@ import { useQuery, useConvexClient } from "convex-svelte"; import { api } from "@sgse-app/backend/convex/_generated/api"; import StatsCard from "$lib/components/ti/StatsCard.svelte"; + import { BarChart3, Users, CheckCircle2, Ban, Clock, Plus, Layers, FileText, Info } from "lucide-svelte"; const client = useConvexClient(); const usuariosQuery = useQuery(api.usuarios.listar, {}); @@ -45,9 +46,7 @@
    - - - +

    Dashboard Administrativo TI

    @@ -62,7 +61,7 @@ @@ -70,7 +69,7 @@ title="Usuários Ativos" value={stats.ativos} description="{stats.total > 0 ? ((stats.ativos / stats.total) * 100).toFixed(1) + '% do total' : '0% do total'}" - icon='' + Icon={CheckCircle2} color="success" /> @@ -78,7 +77,7 @@ title="Usuários Bloqueados" value={stats.bloqueados} description="Requerem atenção" - icon='' + Icon={Ban} color="error" /> @@ -86,7 +85,7 @@ title="Usuários Inativos" value={stats.inativos} description="Desativados" - icon='' + Icon={Clock} color="warning" />
    @@ -102,23 +101,17 @@

    Ações Rápidas

    @@ -127,9 +120,7 @@
    - - - + Sistema de Gestão da Secretaria de Esportes - Versão 2.0 com controle avançado de acesso
    diff --git a/apps/web/src/routes/(dashboard)/ti/perfis/+page.svelte b/apps/web/src/routes/(dashboard)/ti/perfis/+page.svelte index e61b8e5..0da0cea 100644 --- a/apps/web/src/routes/(dashboard)/ti/perfis/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/perfis/+page.svelte @@ -6,6 +6,7 @@ import type { Id } from "@sgse-app/backend/convex/_generated/dataModel"; import { format } from "date-fns"; import { ptBR } from "date-fns/locale"; + import { Users, Shield, AlertTriangle, Info, Building2 } from "lucide-svelte"; type Role = { _id: Id<"roles">; @@ -174,35 +175,35 @@
    diff --git a/apps/web/src/routes/(dashboard)/ti/solicitacoes-acesso/+page.svelte b/apps/web/src/routes/(dashboard)/ti/solicitacoes-acesso/+page.svelte index 1034330..43dfb7f 100644 --- a/apps/web/src/routes/(dashboard)/ti/solicitacoes-acesso/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/solicitacoes-acesso/+page.svelte @@ -6,6 +6,7 @@ import StatsCard from "$lib/components/ti/StatsCard.svelte"; import { format } from "date-fns"; import { ptBR } from "date-fns/locale"; + import { FileText, Clock, CheckCircle2, XCircle } from "lucide-svelte"; type StatusSolicitacao = "pendente" | "aprovado" | "rejeitado"; @@ -294,7 +295,7 @@ @@ -302,7 +303,7 @@ title="Pendentes" value={stats.pendentes} description="{stats.total > 0 ? ((stats.pendentes / stats.total) * 100).toFixed(1) + '% do total' : '0% do total'}" - icon='' + Icon={Clock} color="warning" /> @@ -310,7 +311,7 @@ title="Aprovadas" value={stats.aprovadas} description="{stats.total > 0 ? ((stats.aprovadas / stats.total) * 100).toFixed(1) + '% do total' : '0% do total'}" - icon='' + Icon={CheckCircle2} color="success" /> @@ -318,7 +319,7 @@ title="Rejeitadas" value={stats.rejeitadas} description="{stats.total > 0 ? ((stats.rejeitadas / stats.total) * 100).toFixed(1) + '% do total' : '0% do total'}" - icon='' + Icon={XCircle} color="error" />
    -- 2.49.1 From 616604373526793a37af4356e569410e11590aea Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Wed, 5 Nov 2025 14:05:52 -0300 Subject: [PATCH 4/6] 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. --- apps/web/src/lib/components/ErrorModal.svelte | 73 +++++++--- .../WizardSolicitacaoAusencia.svelte | 1 - .../src/lib/components/chat/ChatWindow.svelte | 37 ++--- .../lib/components/chat/MessageList.svelte | 92 ++++++++++++ .../components/chat/SalaReuniaoManager.svelte | 133 +++++++++++++----- .../chat/ScheduleMessageModal.svelte | 1 + .../routes/(dashboard)/perfil/+page.svelte | 37 +++-- packages/backend/convex/chat.ts | 123 +++++++++++++--- packages/backend/convex/schema.ts | 1 + 9 files changed, 394 insertions(+), 104 deletions(-) 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"]) -- 2.49.1 From 1b02ea7c22aa3287a305d5afb0a1688fcf44b735 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Wed, 5 Nov 2025 15:06:41 -0300 Subject: [PATCH 5/6] feat: enhance notification management with new clearing functionalities - Added functionality to clear all notifications and clear unread notifications for improved user control. - Updated NotificationBell component to support modal display for notifications, enhancing user experience. - Refactored notification handling to separate read and unread notifications, providing clearer organization. - Introduced new UI elements for managing notifications, including buttons for clearing notifications directly from the modal. - Improved backend mutations to handle notification deletion securely, ensuring users can only delete their own notifications. --- .../components/chat/NotificationBell.svelte | 471 ++++++++++++------ .../(dashboard)/ti/notificacoes/+page.svelte | 32 +- packages/backend/convex/chat.ts | 48 ++ 3 files changed, 394 insertions(+), 157 deletions(-) diff --git a/apps/web/src/lib/components/chat/NotificationBell.svelte b/apps/web/src/lib/components/chat/NotificationBell.svelte index 6bf2489..76505b6 100644 --- a/apps/web/src/lib/components/chat/NotificationBell.svelte +++ b/apps/web/src/lib/components/chat/NotificationBell.svelte @@ -6,27 +6,38 @@ import { ptBR } from "date-fns/locale"; import { onMount } from "svelte"; import { authStore } from "$lib/stores/auth.svelte"; - import { Bell, Mail, AtSign, Users, Calendar, Clock, BellOff } from "lucide-svelte"; + import { Bell, Mail, AtSign, Users, Calendar, Clock, BellOff, Trash2, X } from "lucide-svelte"; // Queries e Client const client = useConvexClient(); - const notificacoesQuery = useQuery(api.chat.obterNotificacoes, { - apenasPendentes: true, - }); + // Query para contar apenas não lidas (para o badge) const countQuery = useQuery(api.chat.contarNotificacoesNaoLidas, {}); + // Query para obter TODAS as notificações (para o popup) + const todasNotificacoesQuery = useQuery(api.chat.obterNotificacoes, { + apenasPendentes: false, + }); - let dropdownOpen = $state(false); + let modalOpen = $state(false); let notificacoesFerias = $state>([]); let notificacoesAusencias = $state>([]); + let limpandoNotificacoes = $state(false); // Helpers para obter valores das queries const count = $derived( (typeof countQuery === "number" ? countQuery : countQuery?.data) ?? 0 ); - const notificacoes = $derived( - (Array.isArray(notificacoesQuery) - ? notificacoesQuery - : notificacoesQuery?.data) ?? [] + const todasNotificacoes = $derived( + (Array.isArray(todasNotificacoesQuery) + ? todasNotificacoesQuery + : todasNotificacoesQuery?.data) ?? [] + ); + + // Separar notificações lidas e não lidas + const notificacoesNaoLidas = $derived( + todasNotificacoes.filter((n) => !n.lida) + ); + const notificacoesLidas = $derived( + todasNotificacoes.filter((n) => n.lida) ); // Atualizar contador no store @@ -122,16 +133,40 @@ notificacaoId: notif._id, }); } - dropdownOpen = false; await buscarNotificacoesFerias(); await buscarNotificacoesAusencias(); } + async function handleLimparTodasNotificacoes() { + limpandoNotificacoes = true; + try { + await client.mutation(api.chat.limparTodasNotificacoes, {}); + await buscarNotificacoesFerias(); + await buscarNotificacoesAusencias(); + } catch (error) { + console.error("Erro ao limpar notificações:", error); + } finally { + limpandoNotificacoes = false; + } + } + + async function handleLimparNotificacoesNaoLidas() { + limpandoNotificacoes = true; + try { + await client.mutation(api.chat.limparNotificacoesNaoLidas, {}); + await buscarNotificacoesFerias(); + await buscarNotificacoesAusencias(); + } catch (error) { + console.error("Erro ao limpar notificações não lidas:", error); + } finally { + limpandoNotificacoes = false; + } + } + async function handleClickNotificacao(notificacaoId: string) { await client.mutation(api.chat.marcarNotificacaoLida, { notificacaoId: notificacaoId as any, }); - dropdownOpen = false; } async function handleClickNotificacaoFerias(notificacaoId: string) { @@ -139,7 +174,6 @@ notificacaoId: notificacaoId, }); await buscarNotificacoesFerias(); - dropdownOpen = false; // Redirecionar para a página de férias window.location.href = "/recursos-humanos/ferias"; } @@ -149,37 +183,52 @@ notificacaoId: notificacaoId, }); await buscarNotificacoesAusencias(); - dropdownOpen = false; // Redirecionar para a página de perfil na aba de ausências window.location.href = "/perfil?aba=minhas-ausencias"; } - function toggleDropdown() { - dropdownOpen = !dropdownOpen; + function openModal() { + modalOpen = true; } - // Fechar dropdown ao clicar fora - onMount(() => { + function closeModal() { + modalOpen = false; + } + + // Fechar popup ao clicar fora ou pressionar Escape + $effect(() => { + if (!modalOpen) return; + function handleClickOutside(event: MouseEvent) { const target = event.target as HTMLElement; - if (!target.closest(".notification-bell")) { - dropdownOpen = false; + if (!target.closest(".notification-popup") && !target.closest(".notification-bell")) { + modalOpen = false; + } + } + + function handleEscape(event: KeyboardEvent) { + if (event.key === "Escape") { + modalOpen = false; } } document.addEventListener("click", handleClickOutside); - return () => document.removeEventListener("click", handleClickOutside); + document.addEventListener("keydown", handleEscape); + return () => { + document.removeEventListener("click", handleClickOutside); + document.removeEventListener("keydown", handleEscape); + }; }); - - {/if} + + + {#if todasNotificacoes.length > 0 || notificacoesFerias.length > 0 || notificacoesAusencias.length > 0} +
    +
    + + Total: {todasNotificacoes.length + notificacoesFerias.length + notificacoesAusencias.length} notificações + + {#if notificacoesNaoLidas.length > 0} + + {notificacoesNaoLidas.length} não lidas + + {/if} +
    +
    + {/if} +
    +{/if}
    + diff --git a/apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte b/apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte index ff7e182..78a5641 100644 --- a/apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte @@ -208,6 +208,21 @@ templates.find((t) => t._id === templateId), ); + // Função para renderizar template com variáveis (similar à função do backend) + function renderizarTemplate( + template: string, + variaveis: Record, + ): string { + let resultado = template; + + for (const [chave, valor] of Object.entries(variaveis)) { + const placeholder = `{{${chave}}}`; + resultado = resultado.replace(new RegExp(placeholder, "g"), valor); + } + + return resultado; + } + // Função para mostrar mensagens function mostrarMensagem(tipo: "success" | "error" | "info", texto: string) { mensagem = { tipo, texto }; @@ -733,8 +748,11 @@ ); if (conversaId) { - const mensagem = usarTemplate - ? templateSelecionado?.corpo || "" + const mensagem = usarTemplate && templateSelecionado + ? renderizarTemplate(templateSelecionado.corpo, { + nome: destinatario.nome, + matricula: destinatario.matricula || "", + }) : mensagemPersonalizada; if (agendadaPara) { @@ -988,10 +1006,12 @@ ); if (conversaId) { - // Para templates, usar corpo direto (o backend já faz substituição via email) - // Para mensagem personalizada, usar diretamente - const mensagem = usarTemplate - ? templateSelecionado?.corpo || "" + // Renderizar template com variáveis do destinatário + const mensagem = usarTemplate && templateSelecionado + ? renderizarTemplate(templateSelecionado.corpo, { + nome: destinatario.nome, + matricula: destinatario.matricula || "", + }) : mensagemPersonalizada; if (agendadaPara) { diff --git a/packages/backend/convex/chat.ts b/packages/backend/convex/chat.ts index 2ef4646..b2f024d 100644 --- a/packages/backend/convex/chat.ts +++ b/packages/backend/convex/chat.ts @@ -855,6 +855,54 @@ export const marcarTodasNotificacoesLidas = mutation({ }, }); +/** + * Deleta todas as notificações do usuário + * SEGURANÇA: Usuário só pode deletar suas próprias notificações + */ +export const limparTodasNotificacoes = mutation({ + args: {}, + handler: async (ctx) => { + const usuarioAtual = await getUsuarioAutenticado(ctx); + if (!usuarioAtual) throw new Error("Não autenticado"); + + const notificacoes = await ctx.db + .query("notificacoes") + .withIndex("by_usuario", (q) => q.eq("usuarioId", usuarioAtual._id)) + .collect(); + + for (const notificacao of notificacoes) { + await ctx.db.delete(notificacao._id); + } + + return { excluidas: notificacoes.length }; + }, +}); + +/** + * Deleta apenas as notificações não lidas do usuário + * SEGURANÇA: Usuário só pode deletar suas próprias notificações + */ +export const limparNotificacoesNaoLidas = mutation({ + args: {}, + handler: async (ctx) => { + const usuarioAtual = await getUsuarioAutenticado(ctx); + if (!usuarioAtual) throw new Error("Não autenticado"); + + const notificacoes = await ctx.db + .query("notificacoes") + .withIndex("by_usuario_lida", (q) => + q.eq("usuarioId", usuarioAtual._id).eq("lida", false) + ) + .collect(); + + for (const notificacao of notificacoes) { + await ctx.db.delete(notificacao._id); + } + + return { excluidas: notificacoes.length }; + }, +}); + /** * Deleta uma mensagem (soft delete) */ -- 2.49.1 From 05244b9207c2b3d6098c3614e7ad04ce1befa038 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Wed, 5 Nov 2025 15:16:20 -0300 Subject: [PATCH 6/6] feat: enhance ChatWidget with improved drag-and-resize functionality - Introduced drag threshold and movement detection to prevent unintended clicks during dragging. - Added reactive window dimensions to ensure proper positioning and resizing of the chat widget. - Refactored mouse event handlers for better separation of concerns and improved user experience. - Enhanced position adjustment logic to maintain visibility within the viewport during dragging and resizing. --- .../src/lib/components/chat/ChatWidget.svelte | 178 +++++++++++++++--- 1 file changed, 150 insertions(+), 28 deletions(-) diff --git a/apps/web/src/lib/components/chat/ChatWidget.svelte b/apps/web/src/lib/components/chat/ChatWidget.svelte index 01854d9..02ead38 100644 --- a/apps/web/src/lib/components/chat/ChatWidget.svelte +++ b/apps/web/src/lib/components/chat/ChatWidget.svelte @@ -44,6 +44,8 @@ let isDragging = $state(false); let dragStart = $state({ x: 0, y: 0 }); let isAnimating = $state(false); + let dragThreshold = $state(5); // Distância mínima em pixels para considerar arrastar + let hasMoved = $state(false); // Flag para verificar se houve movimento durante o arrastar // Tamanho da janela (redimensionável) const MIN_WIDTH = 300; @@ -76,6 +78,38 @@ let previousSize = $state({ width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT }); let previousPosition = $state({ x: 0, y: 0 }); + // Dimensões da janela (reativo) + let windowDimensions = $state({ width: 0, height: 0 }); + + // Atualizar dimensões da janela + function updateWindowDimensions() { + if (typeof window !== 'undefined') { + windowDimensions = { + width: window.innerWidth, + height: window.innerHeight + }; + } + } + + // Inicializar e atualizar dimensões da janela + $effect(() => { + if (typeof window === 'undefined') return; + + updateWindowDimensions(); + + const handleResize = () => { + updateWindowDimensions(); + // Ajustar posição quando a janela redimensionar + ajustarPosicao(); + }; + + window.addEventListener('resize', handleResize); + + return () => { + window.removeEventListener('resize', handleResize); + }; + }); + // Salvar tamanho no localStorage function saveSize() { if (typeof window !== 'undefined') { @@ -119,15 +153,19 @@ newWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, resizeStart.width + deltaX)); } if (resizeDirection.includes('w')) { - newWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, resizeStart.width - deltaX)); - newX = position.x + (resizeStart.width - newWidth); + const calculatedWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, resizeStart.width - deltaX)); + const widthDelta = resizeStart.width - calculatedWidth; + newWidth = calculatedWidth; + newX = position.x + widthDelta; } if (resizeDirection.includes('s')) { newHeight = Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, resizeStart.height + deltaY)); } if (resizeDirection.includes('n')) { - newHeight = Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, resizeStart.height - deltaY)); - newY = position.y + (resizeStart.height - newHeight); + const calculatedHeight = Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, resizeStart.height - deltaY)); + const heightDelta = resizeStart.height - calculatedHeight; + newHeight = calculatedHeight; + newY = position.y + heightDelta; } windowSize = { width: newWidth, height: newHeight }; @@ -337,9 +375,12 @@ previousPosition = { ...position }; // Maximizar completamente: usar toda a largura e altura da tela + const winWidth = windowDimensions.width || (typeof window !== 'undefined' ? window.innerWidth : DEFAULT_WIDTH); + const winHeight = windowDimensions.height || (typeof window !== 'undefined' ? window.innerHeight : DEFAULT_HEIGHT); + windowSize = { - width: window.innerWidth, - height: window.innerHeight + width: winWidth, + height: winHeight }; position = { x: 0, @@ -355,6 +396,7 @@ // Funcionalidade de arrastar function handleMouseDown(e: MouseEvent) { if (e.button !== 0) return; // Apenas botão esquerdo + hasMoved = false; isDragging = true; dragStart = { x: e.clientX - position.x, @@ -364,6 +406,20 @@ e.preventDefault(); } + // Handler específico para o botão flutuante (evita conflito com clique) + function handleButtonMouseDown(e: MouseEvent) { + if (e.button !== 0) return; + // Resetar flag de movimento + hasMoved = false; + isDragging = true; + dragStart = { + x: e.clientX - position.x, + y: e.clientY - position.y, + }; + document.body.classList.add('dragging'); + // Não prevenir default para permitir clique funcionar se não houver movimento + } + function handleMouseMove(e: MouseEvent) { if (isResizing) { handleResizeMove(e); @@ -375,15 +431,26 @@ const newX = e.clientX - dragStart.x; const newY = e.clientY - dragStart.y; + // Verificar se houve movimento significativo + const deltaX = Math.abs(newX - position.x); + const deltaY = Math.abs(newY - position.y); + if (deltaX > dragThreshold || deltaY > dragThreshold) { + hasMoved = true; + } + // Dimensões do widget const widgetWidth = isOpen && !isMinimized ? windowSize.width : 72; const widgetHeight = isOpen && !isMinimized ? windowSize.height : 72; + // Usar dimensões reativas da janela + const winWidth = windowDimensions.width || (typeof window !== 'undefined' ? window.innerWidth : 0); + const winHeight = windowDimensions.height || (typeof window !== 'undefined' ? window.innerHeight : 0); + // Limites da tela com margem de segurança const minX = -(widgetWidth - 100); // Permitir até 100px visíveis - const maxX = window.innerWidth - 100; // Manter 100px dentro da tela + const maxX = Math.max(0, winWidth - 100); // Manter 100px dentro da tela const minY = -(widgetHeight - 100); - const maxY = window.innerHeight - 100; + const maxY = Math.max(0, winHeight - 100); position = { x: Math.max(minX, Math.min(newX, maxX)), @@ -391,14 +458,26 @@ }; } - function handleMouseUp() { + function handleMouseUp(e?: MouseEvent) { + const hadMoved = hasMoved; + if (isDragging) { isDragging = false; + hasMoved = false; document.body.classList.remove('dragging'); + + // Se estava arrastando e houve movimento, prevenir clique + if (hadMoved && e) { + e.preventDefault(); + e.stopPropagation(); + } + // Garantir que está dentro dos limites ao soltar ajustarPosicao(); } handleResizeEnd(); + + return !hadMoved; // Retorna true se não houve movimento (permite clique) } function ajustarPosicao() { @@ -408,22 +487,32 @@ const widgetWidth = isOpen && !isMinimized ? windowSize.width : 72; const widgetHeight = isOpen && !isMinimized ? windowSize.height : 72; + // Usar dimensões reativas da janela + const winWidth = windowDimensions.width || (typeof window !== 'undefined' ? window.innerWidth : 0); + const winHeight = windowDimensions.height || (typeof window !== 'undefined' ? window.innerHeight : 0); + // Verificar se está fora dos limites let newX = position.x; let newY = position.y; - // Ajustar X - if (newX < -(widgetWidth - 100)) { - newX = -(widgetWidth - 100); - } else if (newX > window.innerWidth - 100) { - newX = window.innerWidth - 100; + // Ajustar X - garantir que pelo menos 100px fiquem visíveis + const minX = -(widgetWidth - 100); + const maxX = Math.max(0, winWidth - 100); + + if (newX < minX) { + newX = minX; + } else if (newX > maxX) { + newX = maxX; } - // Ajustar Y - if (newY < -(widgetHeight - 100)) { - newY = -(widgetHeight - 100); - } else if (newY > window.innerHeight - 100) { - newY = window.innerHeight - 100; + // Ajustar Y - garantir que pelo menos 100px fiquem visíveis + const minY = -(widgetHeight - 100); + const maxY = Math.max(0, winHeight - 100); + + if (newY < minY) { + newY = minY; + } else if (newY > maxY) { + newY = maxY; } position = { x: newX, y: newY }; @@ -433,15 +522,26 @@ }, 300); } - // Event listeners globais - if (typeof window !== 'undefined') { + // Event listeners globais com cleanup adequado + $effect(() => { + if (typeof window === 'undefined') return; + window.addEventListener('mousemove', handleMouseMove); window.addEventListener('mouseup', handleMouseUp); - } + + return () => { + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + }; + }); {#if !isOpen || isMinimized} + {@const winWidth = windowDimensions.width || (typeof window !== 'undefined' ? window.innerWidth : 0)} + {@const winHeight = windowDimensions.height || (typeof window !== 'undefined' ? window.innerHeight : 0)} + {@const bottomPos = position.y === 0 ? '1.5rem' : `${Math.max(0, winHeight - position.y - 72)}px`} + {@const rightPos = position.x === 0 ? '1.5rem' : `${Math.max(0, winWidth - position.x - 72)}px`}