From e03b6d7a65d86307560723e016924b7292cae632 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Mon, 22 Dec 2025 15:13:07 -0300 Subject: [PATCH 01/12] feat: implement dynamic theme support across chat components, enhancing UI consistency with reactive color updates and gradient functionalities --- .../src/lib/components/chat/ChatList.svelte | 54 ++++++- .../src/lib/components/chat/ChatWidget.svelte | 142 ++++++++++++++---- .../src/lib/components/chat/ChatWindow.svelte | 91 +++++++++-- .../lib/components/chat/MessageInput.svelte | 62 +++++++- .../chat/ScheduleMessageModal.svelte | 60 +++++++- 5 files changed, 367 insertions(+), 42 deletions(-) diff --git a/apps/web/src/lib/components/chat/ChatList.svelte b/apps/web/src/lib/components/chat/ChatList.svelte index 23fea2b..625d8ab 100644 --- a/apps/web/src/lib/components/chat/ChatList.svelte +++ b/apps/web/src/lib/components/chat/ChatList.svelte @@ -9,6 +9,7 @@ import NewConversationModal from './NewConversationModal.svelte'; import { Search, Plus, MessageSquare, Users, UsersRound } from 'lucide-svelte'; import type { Id, Doc } from '@sgse-app/backend/convex/_generated/dataModel'; + import { obterCoresDoTema } from '$lib/utils/temas'; const client = useConvexClient(); @@ -23,6 +24,57 @@ let searchQuery = $state(''); let activeTab = $state<'usuarios' | 'conversas'>('usuarios'); + + // Obter cores do tema atual (reativo) + let coresTema = $state(obterCoresDoTema()); + + // Atualizar cores quando o tema mudar + $effect(() => { + if (typeof window === 'undefined') return; + + const atualizarCores = () => { + coresTema = obterCoresDoTema(); + }; + + atualizarCores(); + + window.addEventListener('themechange', atualizarCores); + + const observer = new MutationObserver(atualizarCores); + const htmlElement = document.documentElement; + observer.observe(htmlElement, { + attributes: true, + attributeFilter: ['data-theme'] + }); + + return () => { + window.removeEventListener('themechange', atualizarCores); + observer.disconnect(); + }; + }); + + // Função para obter rgba da cor primária + function obterPrimariaRgba(alpha: number = 1) { + const primary = coresTema.primary; + if (primary.startsWith('rgba')) { + const match = primary.match(/rgba?\(([^)]+)\)/); + if (match) { + const values = match[1].split(','); + return `rgba(${values[0]}, ${values[1]}, ${values[2]}, ${alpha})`; + } + } + if (primary.startsWith('#')) { + const hex = primary.replace('#', ''); + const r = parseInt(hex.substring(0, 2), 16); + const g = parseInt(hex.substring(2, 4), 16); + const b = parseInt(hex.substring(4, 6), 16); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; + } + if (primary.startsWith('hsl')) { + return primary.replace(/\)$/, `, ${alpha})`).replace('hsl', 'hsla'); + } + return `rgba(102, 126, 234, ${alpha})`; + } // Debug: monitorar carregamento de dados $effect(() => { @@ -263,7 +315,7 @@
diff --git a/apps/web/src/lib/components/chat/ChatWidget.svelte b/apps/web/src/lib/components/chat/ChatWidget.svelte index 64a893e..970b0ba 100644 --- a/apps/web/src/lib/components/chat/ChatWidget.svelte +++ b/apps/web/src/lib/components/chat/ChatWidget.svelte @@ -17,6 +17,7 @@ import ChatList from './ChatList.svelte'; import ChatWindow from './ChatWindow.svelte'; import { MessageSquare, Minus, Maximize2, X, Bell } from 'lucide-svelte'; + import { obterCoresDoTema, obterTemaPersistidoNoLocalStorage } from '$lib/utils/temas'; const count = useQuery(api.chat.contarNotificacoesNaoLidas, {}); @@ -955,6 +956,80 @@ window.removeEventListener('touchend', handleTouchEnd); }; }); + + // Obter cores do tema atual (reativo) + let coresTema = $state(obterCoresDoTema()); + + // Atualizar cores quando o tema mudar + $effect(() => { + if (typeof window === 'undefined') return; + + const atualizarCores = () => { + coresTema = obterCoresDoTema(); + }; + + // Atualizar cores inicialmente + atualizarCores(); + + // Escutar mudanças de tema + window.addEventListener('themechange', atualizarCores); + + // Observar mudanças no atributo data-theme do HTML + const observer = new MutationObserver(atualizarCores); + const htmlElement = document.documentElement; + observer.observe(htmlElement, { + attributes: true, + attributeFilter: ['data-theme'] + }); + + return () => { + window.removeEventListener('themechange', atualizarCores); + observer.disconnect(); + }; + }); + + // Função para obter gradiente do tema + function obterGradienteTema() { + const primary = coresTema.primary; + // Criar variações da cor primária para o gradiente + return `linear-gradient(135deg, ${primary} 0%, ${primary}dd 50%, ${primary}bb 100%)`; + } + + // Função para obter rgba da cor primária + function obterPrimariaRgba(alpha: number = 1) { + const primary = coresTema.primary.trim(); + // Se já for rgba, extrair os valores + if (primary.startsWith('rgba')) { + const match = primary.match(/rgba?\(([^)]+)\)/); + if (match) { + const values = match[1].split(',').map(v => v.trim()); + if (values.length >= 3) { + return `rgba(${values[0]}, ${values[1]}, ${values[2]}, ${alpha})`; + } + } + } + // Se for hex, converter + if (primary.startsWith('#')) { + const hex = primary.replace('#', ''); + if (hex.length === 6) { + const r = parseInt(hex.substring(0, 2), 16); + const g = parseInt(hex.substring(2, 4), 16); + const b = parseInt(hex.substring(4, 6), 16); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; + } + } + // Se for hsl, converter para hsla + if (primary.startsWith('hsl')) { + const match = primary.match(/hsl\(([^)]+)\)/); + if (match) { + return `hsla(${match[1]}, ${alpha})`; + } + // Fallback: tentar adicionar alpha + return primary.replace(/\)$/, `, ${alpha})`).replace('hsl', 'hsla'); + } + // Fallback padrão + return `rgba(102, 126, 234, ${alpha})`; + } @@ -975,10 +1050,10 @@ bottom: {bottomPos}; right: {rightPos}; position: fixed !important; - background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%); + background: {obterGradienteTema()}; box-shadow: - 0 20px 60px -10px rgba(102, 126, 234, 0.5), - 0 10px 30px -5px rgba(118, 75, 162, 0.4), + 0 20px 60px -10px {obterPrimariaRgba(0.5)}, + 0 10px 30px -5px {obterPrimariaRgba(0.4)}, 0 0 0 1px rgba(255, 255, 255, 0.1) inset; border-radius: 50%; cursor: {isDragging ? 'grabbing' : 'grab'}; @@ -1058,17 +1133,17 @@ strokeWidth={2} /> - + {#if count?.data && count.data > 0} @@ -1121,8 +1196,8 @@
handleResizeStart(e, 'n')} onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 'n')} style="border-radius: 24px 24px 0 0;" @@ -1249,7 +1325,8 @@ role="button" tabindex="0" aria-label="Redimensionar janela pela borda inferior" - class="hover:bg-primary/20 absolute right-0 bottom-0 left-0 z-50 h-2 cursor-ns-resize transition-colors" + class="absolute right-0 bottom-0 left-0 z-50 h-2 cursor-ns-resize transition-colors" + style="--hover-bg: {obterPrimariaRgba(0.2)}" onmousedown={(e) => handleResizeStart(e, 's')} onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 's')} style="border-radius: 0 0 24px 24px;" @@ -1259,7 +1336,8 @@ role="button" tabindex="0" aria-label="Redimensionar janela pela borda esquerda" - class="hover:bg-primary/20 absolute top-0 bottom-0 left-0 z-50 w-2 cursor-ew-resize transition-colors" + class="absolute top-0 bottom-0 left-0 z-50 w-2 cursor-ew-resize transition-colors" + style="--hover-bg: {obterPrimariaRgba(0.2)}" onmousedown={(e) => handleResizeStart(e, 'w')} onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 'w')} style="border-radius: 24px 0 0 24px;" @@ -1269,7 +1347,8 @@ role="button" tabindex="0" aria-label="Redimensionar janela pela borda direita" - class="hover:bg-primary/20 absolute top-0 right-0 bottom-0 z-50 w-2 cursor-ew-resize transition-colors" + class="absolute top-0 right-0 bottom-0 z-50 w-2 cursor-ew-resize transition-colors" + style="--hover-bg: {obterPrimariaRgba(0.2)}" onmousedown={(e) => handleResizeStart(e, 'e')} onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 'e')} style="border-radius: 0 24px 24px 0;" @@ -1279,7 +1358,8 @@ role="button" tabindex="0" aria-label="Redimensionar janela pelo canto superior esquerdo" - class="hover:bg-primary/20 absolute top-0 left-0 z-50 h-4 w-4 cursor-nwse-resize transition-colors" + class="absolute top-0 left-0 z-50 h-4 w-4 cursor-nwse-resize transition-colors" + style="--hover-bg: {obterPrimariaRgba(0.2)}" onmousedown={(e) => handleResizeStart(e, 'nw')} onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 'nw')} style="border-radius: 24px 0 0 0;" @@ -1288,7 +1368,8 @@ role="button" tabindex="0" aria-label="Redimensionar janela pelo canto superior direito" - class="hover:bg-primary/20 absolute top-0 right-0 z-50 h-4 w-4 cursor-nesw-resize transition-colors" + class="absolute top-0 right-0 z-50 h-4 w-4 cursor-nesw-resize transition-colors" + style="--hover-bg: {obterPrimariaRgba(0.2)}" onmousedown={(e) => handleResizeStart(e, 'ne')} onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 'ne')} style="border-radius: 0 24px 0 0;" @@ -1297,7 +1378,8 @@ role="button" tabindex="0" aria-label="Redimensionar janela pelo canto inferior esquerdo" - class="hover:bg-primary/20 absolute bottom-0 left-0 z-50 h-4 w-4 cursor-nesw-resize transition-colors" + class="absolute bottom-0 left-0 z-50 h-4 w-4 cursor-nesw-resize transition-colors" + style="--hover-bg: {obterPrimariaRgba(0.2)}" onmousedown={(e) => handleResizeStart(e, 'sw')} onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 'sw')} style="border-radius: 0 0 0 24px;" @@ -1306,7 +1388,8 @@ role="button" tabindex="0" aria-label="Redimensionar janela pelo canto inferior direito" - class="hover:bg-primary/20 absolute right-0 bottom-0 z-50 h-4 w-4 cursor-nwse-resize transition-colors" + class="absolute right-0 bottom-0 z-50 h-4 w-4 cursor-nwse-resize transition-colors" + style="--hover-bg: {obterPrimariaRgba(0.2)}" onmousedown={(e) => handleResizeStart(e, 'se')} onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 'se')} style="border-radius: 0 0 24px 0;" @@ -1324,8 +1407,8 @@ role="button" tabindex="0" aria-label="Abrir conversa: Nova mensagem de {notificationMsg.remetente}" - class="bg-base-100 border-primary/20 fixed top-4 right-4 z-1000 max-w-sm cursor-pointer rounded-lg border p-4 shadow-2xl" - style="box-shadow: 0 10px 40px -10px rgba(0,0,0,0.3); animation: slideInRight 0.3s ease-out;" + class="bg-base-100 fixed top-4 right-4 z-1000 max-w-sm cursor-pointer rounded-lg border p-4 shadow-2xl" + style="border-color: {obterPrimariaRgba(0.2)}; box-shadow: 0 10px 40px -10px rgba(0,0,0,0.3); animation: slideInRight 0.3s ease-out;" onclick={() => { const conversaIdToOpen = notificationMsg?.conversaId; showGlobalNotificationPopup = false; @@ -1356,8 +1439,8 @@ }} >
-
- +
+

@@ -1366,7 +1449,7 @@

{notificationMsg.conteudo}

-

Clique para abrir

+

Clique para abrir

@@ -289,7 +351,7 @@ userId={conversa()?.outroUsuario?._id} /> {:else} -
+
{getAvatarConversa()}
{/if} @@ -380,7 +442,8 @@
{#if conversa()?.tipo === 'sala_reuniao' && isAdmin?.data} • Admin {/if} @@ -727,10 +790,8 @@ {#each searchResults as resultado, index (resultado._id)}
handleResizeStart(e, 's')} onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 's')} - style="border-radius: 0 0 24px 24px;" >
handleResizeStart(e, 'w')} onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 'w')} - style="border-radius: 24px 0 0 24px;" >
handleResizeStart(e, 'e')} onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 'e')} - style="border-radius: 0 24px 24px 0;" >
handleResizeStart(e, 'nw')} onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 'nw')} - style="border-radius: 24px 0 0 0;" >
handleResizeStart(e, 'ne')} onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 'ne')} - style="border-radius: 0 24px 0 0;" >
handleResizeStart(e, 'sw')} onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 'sw')} - style="border-radius: 0 0 0 24px;" >
handleResizeStart(e, 'se')} onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 'se')} - style="border-radius: 0 0 24px 0;" >
diff --git a/apps/web/src/lib/components/ponto/RegistroPonto.svelte b/apps/web/src/lib/components/ponto/RegistroPonto.svelte index 3a76e19..f714c8e 100644 --- a/apps/web/src/lib/components/ponto/RegistroPonto.svelte +++ b/apps/web/src/lib/components/ponto/RegistroPonto.svelte @@ -35,6 +35,9 @@ const client = useConvexClient(); + // Estado de sincronização do relógio + let sincronizacaoConcluida = $state(false); + // Chave de refresh para forçar atualização das queries após registro let refreshKey = $state(0); @@ -60,17 +63,12 @@ funcionarioId && dataHoje ? { funcionarioId, data: dataHoje } : 'skip' ); - const registrosHojeQuery = $derived.by(() => - useQuery(api.pontos.listarRegistrosDia, registrosHojeParams) - ); + // Queries de ponto - usando useQuery com parâmetros derivados reativos + const registrosHojeQuery = useQuery(api.pontos.listarRegistrosDia, registrosHojeParams); - const historicoSaldoQuery = $derived.by(() => - useQuery(api.pontos.obterHistoricoESaldoDia, historicoSaldoParams) - ); + const historicoSaldoQuery = useQuery(api.pontos.obterHistoricoESaldoDia, historicoSaldoParams); - const dispensaQuery = $derived.by(() => - useQuery(api.pontos.verificarDispensaAtiva, dispensaParams) - ); + const dispensaQuery = useQuery(api.pontos.verificarDispensaAtiva, dispensaParams); // Query para obter status atual do funcionário (férias/licença) const funcionarioStatusQuery = useQuery( @@ -355,6 +353,9 @@ justificativa = ''; // Limpar justificativa após registro mostrandoModalConfirmacao = false; + // Aguardar um pouco para garantir que o backend processou o registro + await new Promise((resolve) => setTimeout(resolve, 800)); + // Forçar atualização das queries para mostrar o novo registro refreshKey++; @@ -362,11 +363,13 @@ console.log('[RegistroPonto] Registro bem-sucedido, refreshKey incrementado:', refreshKey); } - // Aguardar um pouco para garantir que o backend processou o registro - await new Promise((resolve) => setTimeout(resolve, 500)); - - // Forçar mais uma atualização após o delay para garantir sincronização - refreshKey++; + // Aguardar mais um pouco e forçar outra atualização para garantir sincronização completa + setTimeout(() => { + refreshKey++; + if (import.meta.env.DEV) { + console.log('[RegistroPonto] Segunda atualização, refreshKey incrementado:', refreshKey); + } + }, 1500); // Mostrar comprovante após 1 segundo setTimeout(() => { @@ -500,25 +503,27 @@ timestampBase = Date.now(); } - // Aplicar GMT offset ao timestamp - // Quando GMT é 0, usar timestamp UTC puro e deixar toLocaleTimeString() fazer a conversão automática - // Quando GMT ≠ 0, aplicar offset configurado ao timestamp + // Aplicar GMT offset ao timestamp (o horário já vem corrigido do servidor) + // Apenas aplicar o offset configurado, sem ajustes adicionais de timezone let timestamp: number; if (gmtOffset !== 0) { // Aplicar offset configurado timestamp = timestampBase + gmtOffset * 60 * 60 * 1000; } else { - // Quando GMT = 0, manter timestamp UTC puro - // O toLocaleTimeString() converterá automaticamente para o timezone local do navegador + // Quando GMT = 0, usar timestamp base diretamente (já vem corrigido) timestamp = timestampBase; } + // Usar métodos UTC diretamente para evitar conversão automática do navegador + // O timestamp já está ajustado, então formatamos como UTC para manter o valor correto const dataObj = new Date(timestamp); - const data = dataObj.toLocaleDateString('pt-BR'); - const hora = dataObj.toLocaleTimeString('pt-BR', { - hour: '2-digit', - minute: '2-digit', - second: '2-digit' - }); + const dia = String(dataObj.getUTCDate()).padStart(2, '0'); + const mes = String(dataObj.getUTCMonth() + 1).padStart(2, '0'); + const ano = dataObj.getUTCFullYear(); + const data = `${dia}/${mes}/${ano}`; + const horaStr = String(dataObj.getUTCHours()).padStart(2, '0'); + const minutoStr = String(dataObj.getUTCMinutes()).padStart(2, '0'); + const segundoStr = String(dataObj.getUTCSeconds()).padStart(2, '0'); + const hora = `${horaStr}:${minutoStr}:${segundoStr}`; dataHoraAtual = { data, hora }; } catch (error) { console.warn('Erro ao obter tempo do servidor, usando tempo local:', error); @@ -866,7 +871,8 @@ !estaDispensado && !emFerias && !emLicenca && - temFuncionarioAssociado + temFuncionarioAssociado && + sincronizacaoConcluida // Só permitir registro após sincronização concluída ); }); @@ -1131,24 +1137,40 @@ id="relogio-sincronizado-ref" class="card from-primary/10 to-primary/5 border-primary/20 w-full max-w-sm rounded-2xl border-2 bg-linear-to-br p-5 shadow-lg" > - + + + {#if !sincronizacaoConcluida} +
+ +
+

Aguarde a sincronização

+
+ O sistema está sincronizando o horário com o servidor. O botão de registro será habilitado + após a conclusão da sincronização. +
+
+
+ {/if} + @@ -1739,7 +1769,12 @@ {/if} @@ -2438,6 +2473,17 @@ }} /> + + + {#if documentoModal.aberto} diff --git a/packages/backend/convex/atestadosLicencas.ts b/packages/backend/convex/atestadosLicencas.ts index 767dda3..469f0bd 100644 --- a/packages/backend/convex/atestadosLicencas.ts +++ b/packages/backend/convex/atestadosLicencas.ts @@ -182,7 +182,7 @@ export const listarTodos = query({ fotoPerfilUrl, criadoPorNome: criadoPor?.nome || 'Sistema', dias: calcularDias(a.dataInicio, a.dataFim), - status: new Date(a.dataFim) >= new Date() ? 'ativo' : 'finalizado' + status: new Date() > new Date(a.dataFim) ? 'finalizado' : 'ativo' }; } catch (error) { console.error('Erro ao buscar detalhes do atestado:', error); @@ -192,7 +192,7 @@ export const listarTodos = query({ fotoPerfilUrl: null, criadoPorNome: 'Sistema', dias: calcularDias(a.dataInicio, a.dataFim), - status: new Date(a.dataFim) >= new Date() ? 'ativo' : 'finalizado' + status: new Date() > new Date(a.dataFim) ? 'finalizado' : 'ativo' }; } }) @@ -226,7 +226,7 @@ export const listarTodos = query({ criadoPorNome: criadoPor?.nome || 'Sistema', licencaOriginal, dias: calcularDias(l.dataInicio, l.dataFim), - status: new Date(l.dataFim) >= new Date() ? 'ativo' : 'finalizado' + status: new Date() > new Date(l.dataFim) ? 'finalizado' : 'ativo' }; } catch (error) { console.error('Erro ao buscar detalhes da licença:', error); @@ -237,7 +237,7 @@ export const listarTodos = query({ criadoPorNome: 'Sistema', licencaOriginal: null, dias: calcularDias(l.dataInicio, l.dataFim), - status: new Date(l.dataFim) >= new Date() ? 'ativo' : 'finalizado' + status: new Date() > new Date(l.dataFim) ? 'finalizado' : 'ativo' }; } }) @@ -1255,6 +1255,32 @@ export const excluirAtestado = mutation({ const dataInicio = atestado.dataInicio; // Data início do atestado const dataFim = atestado.dataFim; // Data fim do atestado const atestadoId = args.id.toString(); // ID do atestado para remover ajustes + const documentoId = atestado.documentoId; // ID do documento para remover do storage + + // Remover logs de atividades relacionados ao atestado + try { + const logs = await ctx.db + .query('logsAtividades') + .withIndex('by_recurso_id', (q) => + q.eq('recurso', 'atestados').eq('recursoId', atestadoId) + ) + .collect(); + for (const log of logs) { + await ctx.db.delete(log._id); + } + } catch (error) { + console.error('[excluirAtestado] Erro ao remover logs de atividades:', error); + } + + // Remover documento do storage se existir + if (documentoId) { + try { + await ctx.storage.delete(documentoId); + } catch (error) { + console.error('[excluirAtestado] Erro ao remover documento do storage:', error); + // Não falhar a exclusão se o documento não existir mais + } + } // Excluir o registro do banco de dados await ctx.db.delete(args.id); @@ -1319,6 +1345,33 @@ export const excluirLicenca = mutation({ const funcionarioId = licenca.funcionarioId; const dataInicio = licenca.dataInicio; // Data início da licença const dataFim = licenca.dataFim; // Data fim da licença + const licencaId = args.id.toString(); // ID da licença para remover logs + const documentoId = licenca.documentoId; // ID do documento para remover do storage + + // Remover logs de atividades relacionados à licença + try { + const logs = await ctx.db + .query('logsAtividades') + .withIndex('by_recurso_id', (q) => + q.eq('recurso', 'licencas').eq('recursoId', licencaId) + ) + .collect(); + for (const log of logs) { + await ctx.db.delete(log._id); + } + } catch (error) { + console.error('[excluirLicenca] Erro ao remover logs de atividades:', error); + } + + // Remover documento do storage se existir + if (documentoId) { + try { + await ctx.storage.delete(documentoId); + } catch (error) { + console.error('[excluirLicenca] Erro ao remover documento do storage:', error); + // Não falhar a exclusão se o documento não existir mais + } + } // Excluir o registro do banco de dados await ctx.db.delete(args.id); @@ -1332,6 +1385,19 @@ export const excluirLicenca = mutation({ args.id ); + // Remover ajustes automáticos relacionados à licença excluída + try { + await ctx.runMutation(internal.pontos.removerAjustesAutomaticosInternal, { + funcionarioId, + motivoTipo: 'licenca', + motivoId: licencaId, + dataInicio, + dataFim + }); + } catch (error) { + console.error('[excluirLicenca] Erro ao remover ajustes automáticos:', error); + } + // Recalcular banco de horas APENAS para o período específico da licença excluída // Isso garante que os dias da licença sejam removidos corretamente dos registros de ponto await recalcularBancoHorasPeriodo(ctx, funcionarioId, dataInicio, dataFim); -- 2.49.1 From c6a52155eef4618797fe98d1287f8e8373bef150 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Tue, 23 Dec 2025 22:18:30 -0300 Subject: [PATCH 06/12] feat: restore original values for linked records upon homologation deletion, including recalculation of work hours based on previous time entries, enhancing data integrity and user experience --- packages/backend/convex/pontos.ts | 33 ++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/packages/backend/convex/pontos.ts b/packages/backend/convex/pontos.ts index 59ac91a..6fe3951 100644 --- a/packages/backend/convex/pontos.ts +++ b/packages/backend/convex/pontos.ts @@ -2867,14 +2867,41 @@ export const excluirHomologacao = mutation({ throw new Error('Você não tem permissão para excluir esta homologação'); } - // Se a homologação estiver vinculada a um registro, remover a referência + // Se a homologação estiver vinculada a um registro, restaurar valores originais if (homologacao.registroId) { const registro = await ctx.db.get(homologacao.registroId); if (registro && registro.homologacaoId === args.homologacaoId) { - await ctx.db.patch(homologacao.registroId, { + // Restaurar valores originais se existirem + const patchData: { + homologacaoId: undefined; + editadoPorGestor: boolean; + hora?: number; + minuto?: number; + } = { homologacaoId: undefined, editadoPorGestor: false - }); + }; + + // Se a homologação tem valores anteriores, restaurar + if ( + homologacao.horaAnterior !== undefined && + homologacao.minutoAnterior !== undefined + ) { + patchData.hora = homologacao.horaAnterior; + patchData.minuto = homologacao.minutoAnterior; + } + + await ctx.db.patch(homologacao.registroId, patchData); + + // Recalcular banco de horas após restaurar valores + const config = await ctx.db + .query('configuracaoPonto') + .withIndex('by_ativo', (q) => q.eq('ativo', true)) + .first(); + + if (config) { + await atualizarBancoHoras(ctx, registro.funcionarioId, registro.data, config); + } } } -- 2.49.1 From e548c2c678f5010484a74a26066a8ede83cf0ff0 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Tue, 23 Dec 2025 23:06:35 -0300 Subject: [PATCH 07/12] feat: streamline date validation in dispensa functionality by comparing date strings directly, avoiding timezone issues, and enhance date formatting for improved user readability --- .../controle-ponto/dispensa/+page.svelte | 12 +++++++----- packages/backend/convex/pontos.ts | 8 +++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/controle-ponto/dispensa/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/controle-ponto/dispensa/+page.svelte index 78941a9..0286283 100644 --- a/apps/web/src/routes/(dashboard)/recursos-humanos/controle-ponto/dispensa/+page.svelte +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/controle-ponto/dispensa/+page.svelte @@ -100,10 +100,9 @@ return; } - const dataInicioObj = new Date(dataInicio); - const dataFimObj = new Date(dataFim); - - if (dataFimObj < dataInicioObj) { + // Validar datas (comparar strings diretamente para evitar problemas de timezone) + // Formato YYYY-MM-DD permite comparação lexicográfica + if (dataFim < dataInicio) { toast.error('Data fim deve ser maior ou igual à data início'); return; } @@ -163,7 +162,10 @@ } function formatarDataHora(data: string, hora: number, minuto: number): string { - return `${new Date(data).toLocaleDateString('pt-BR')} ${hora.toString().padStart(2, '0')}:${minuto.toString().padStart(2, '0')}`; + // Converter YYYY-MM-DD para DD/MM/YYYY sem problemas de timezone + const [ano, mes, dia] = data.split('-'); + const dataFormatada = `${dia}/${mes}/${ano}`; + return `${dataFormatada} ${hora.toString().padStart(2, '0')}:${minuto.toString().padStart(2, '0')}`; } diff --git a/packages/backend/convex/pontos.ts b/packages/backend/convex/pontos.ts index 6fe3951..b23f8b1 100644 --- a/packages/backend/convex/pontos.ts +++ b/packages/backend/convex/pontos.ts @@ -2978,11 +2978,9 @@ export const criarDispensaRegistro = mutation({ throw new Error('Você não tem permissão para criar dispensa para este funcionário'); } - // Validar datas - const dataInicioObj = new Date(args.dataInicio); - const dataFimObj = new Date(args.dataFim); - - if (dataFimObj < dataInicioObj) { + // Validar datas (comparar strings diretamente para evitar problemas de timezone) + // Formato YYYY-MM-DD permite comparação lexicográfica + if (args.dataFim < args.dataInicio) { throw new Error('Data fim deve ser maior ou igual à data início'); } -- 2.49.1 From b248472d6529630ae9e0ba361c11076d237616ed Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Wed, 24 Dec 2025 08:26:47 -0300 Subject: [PATCH 08/12] feat: enhance dashboard functionality by adding user statistics, improving data filtering for dispensas, and refining timestamp handling to ensure accurate time zone management --- apps/web/src/routes/(dashboard)/+page.svelte | 815 +++++++----------- .../controle-ponto/dispensa/+page.svelte | 61 +- packages/backend/convex/dashboard.ts | 43 +- packages/backend/convex/monitoramento.ts | 221 ----- packages/backend/convex/pontos.ts | 197 +++-- 5 files changed, 500 insertions(+), 837 deletions(-) diff --git a/apps/web/src/routes/(dashboard)/+page.svelte b/apps/web/src/routes/(dashboard)/+page.svelte index 1117734..dae1a87 100644 --- a/apps/web/src/routes/(dashboard)/+page.svelte +++ b/apps/web/src/routes/(dashboard)/+page.svelte @@ -1,20 +1,14 @@ @@ -176,17 +198,23 @@ {/if} - -
+ +
-
-

+
+

{getSaudacao()}! 👋

-

- Bem-vindo ao SGSE - Sistema de Gerenciamento de Secretaria +

Bem-vindo ao SGSE

+

+ Simplificando a Gestão Pública

-

+

Sistema de Gerenciamento de Secretaria

+

{currentTime.toLocaleDateString('pt-BR', { weekday: 'long', year: 'numeric', @@ -197,518 +225,259 @@ {currentTime.toLocaleTimeString('pt-BR')}

-
-
Sistema Online
+
+
Sistema Online
Atualizado
+
Disponível 24h

- + {#if statsQuery.isLoading}
{:else if statsQuery.data} -
- +
+
-
-

Total de Funcionários

-

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

-

- {statsQuery.data.funcionariosAtivos} ativos +

+

+ Usuários Cadastrados

+

+ {#if statsQuery.data} + 0 + {:else} + 0 + {/if} +

+

no sistema

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

Solicitações Pendentes

-

4

-

de 5 total

+
+

+ Funcionários Ativos +

+

+ {#if statsQuery.data} + 0 + {:else} + 0 + {/if} +

+

em atividade

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

+ Cadastros Realizados +

+

+ {#if statsQuery.data} + 0 + {:else} + 0 + {/if} +

+

total de registros

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

+ Disponibilidade +

+

24h

+

funcionando continuamente

+
+
- - -
-
-
-
-

Símbolos Cadastrados

-

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

-

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

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

Atividade (24h)

-

- {activityQuery.data.funcionariosCadastrados24h} cadastros -

-
-
- -
-
-
-
- {/if} -
- - - {#if statusSistemaQuery?.data} - {@const status = statusSistemaQuery.data} - {@const atividade = atividadeBDQuery?.data || { - historico: Array.from({ length: 30 }, () => ({ entradas: 0, saidas: 0 })) - }} - {@const distribuicao = distribuicaoQuery?.data || { - queries: 0, - mutations: 0, - leituras: 0, - escritas: 0 - }} - {@const maxAtividade = - atividade.historico && atividade.historico.length > 0 - ? Math.max( - 1, - ...atividade.historico.map((p) => Math.max(p.entradas || 0, p.saidas || 0)) - ) - : 1} - -
-
-
- -
-
-

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)} - {val} - {/each} -
- - -
- - {#each [0, 1, 2, 3, 4, 5] as i (i)} -
- {/each} - - -
- {#each atividade.historico || [] as ponto, idx (idx)} - {@const entradas = ponto?.entradas || 0} - {@const saidas = ponto?.saidas || 0} -
- -
- -
- - -
-
↑ {entradas} entradas
-
↓ {saidas} saídas
-
-
- {/each} -
-
- - -
- - -
- -60s - -30s - agora -
-
- - -
-
-
- Entradas no BD -
-
-
- Saídas do BD -
-
-
-
- - -
-
-
-

Tipos de Operações

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

Operações no Banco

-
-
-
- Leituras - {distribuicao?.leituras ?? 0} -
- -
-
-
- Escritas - {distribuicao?.escritas ?? 0} -
- -
-
-
-
-
-
- {/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 -

-
-
-
-
- {:else} - -
- Não foi possível carregar os dados do dashboard.
{/if} + + +
+
+

Sobre o SGSE

+

+ Simplificando a Gestão Pública +

+

+ O Sistema de Gerenciamento de Secretaria (SGSE) é uma solução completa e moderna + desenvolvida para otimizar e simplificar os processos administrativos da gestão pública. + Com tecnologia de ponta e interface intuitiva, oferecemos rapidez, comodidade e + disponibilidade 24 horas por dia para atender às necessidades dos nossos usuários. +

+

+ Nossa plataforma integra todas as funcionalidades essenciais em um único ambiente, + permitindo gestão eficiente de funcionários, controle de ponto, férias, licenças, símbolos + e muito mais. Trabalhamos continuamente para garantir que você tenha acesso rápido e + seguro a todas as informações e ferramentas necessárias para uma gestão pública de + excelência. +

+
+
+ + + @@ -724,7 +493,37 @@ } } - .card { - animation: fadeIn 0.5s ease-out; + .fade-in { + animation: fadeIn 0.6s ease-out; + } + + .fade-in-delay { + animation: fadeIn 0.8s ease-out 0.2s both; + } + + .fade-in-delay-2 { + animation: fadeIn 1s ease-out 0.4s both; + } + + .stats-card { + animation: fadeIn 0.6s ease-out; + } + + .feature-card { + animation: fadeIn 0.8s ease-out; + } + + @keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } + } + + .animate-pulse { + animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; } diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/controle-ponto/dispensa/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/controle-ponto/dispensa/+page.svelte index 0286283..77e66b8 100644 --- a/apps/web/src/routes/(dashboard)/recursos-humanos/controle-ponto/dispensa/+page.svelte +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/controle-ponto/dispensa/+page.svelte @@ -13,6 +13,7 @@ let modoCriacao = $state(false); let mostrandoModalExcluir = $state(false); let dispensaParaExcluir = $state | null>(null); + let filtroStatus = $state<'todas' | 'ativas' | 'expiradas'>('todas'); // Formulário let dataInicio = $state(new Date().toISOString().split('T')[0]!); @@ -25,22 +26,33 @@ // Computed para converter time string para hora/minuto let horaInicio = $derived.by(() => { const [hora, minuto] = horaInicioTime.split(':').map(Number); - return { hora: hora || 8, minuto: minuto || 0 }; + return { hora: isNaN(hora) ? 8 : hora, minuto: isNaN(minuto) ? 0 : minuto }; }); let horaFim = $derived.by(() => { const [hora, minuto] = horaFimTime.split(':').map(Number); - return { hora: hora || 18, minuto: minuto || 0 }; + return { hora: isNaN(hora) ? 18 : hora, minuto: isNaN(minuto) ? 0 : minuto }; }); // Queries const subordinadosQuery = useQuery(api.times.listarSubordinadosDoGestorAtual, {}); - const dispensasQuery = useQuery(api.pontos.listarDispensas, { - apenasAtivas: true // Mostrar apenas dispensas ativas - }); + const dispensasQuery = useQuery(api.pontos.listarDispensas, {}); let subordinados = $derived(subordinadosQuery?.data || []); - let dispensas = $derived(dispensasQuery?.data || []); + let todasDispensas = $derived(dispensasQuery?.data || []); + + // Filtrar dispensas baseado no filtro selecionado + let dispensas = $derived.by(() => { + if (filtroStatus === 'todas') { + return todasDispensas; + } else if (filtroStatus === 'ativas') { + // Ativas: não expiradas (inclui isentos que já começaram) + return todasDispensas.filter((d) => !d.expirada); + } else { + // Expiradas: apenas dispensas não isentas que expiraram + return todasDispensas.filter((d) => d.expirada && !d.isento); + } + }); // Lista de funcionários do time let funcionarios = $derived.by(() => { @@ -313,11 +325,42 @@
-

Dispensas Ativas

+
+

Dispensas

+ +
+ + + +
+
{#if dispensas.length === 0}
- Nenhuma dispensa ativa encontrada + + {#if filtroStatus === 'todas'} + Nenhuma dispensa encontrada + {:else if filtroStatus === 'ativas'} + Nenhuma dispensa ativa encontrada + {:else} + Nenhuma dispensa expirada encontrada + {/if} +
{:else}
@@ -373,7 +416,7 @@ {#if dispensa.isento} Isento (sem expiração) {:else if dispensa.expirada} - Expirada + Não ativo {:else} Ativa {/if} diff --git a/packages/backend/convex/dashboard.ts b/packages/backend/convex/dashboard.ts index 16aba68..6159206 100644 --- a/packages/backend/convex/dashboard.ts +++ b/packages/backend/convex/dashboard.ts @@ -7,10 +7,12 @@ export const getStats = query({ returns: v.object({ totalFuncionarios: v.number(), totalSimbolos: v.number(), + totalUsuarios: v.number(), funcionariosAtivos: v.number(), funcionariosDesligados: v.number(), cargoComissionado: v.number(), - funcaoGratificada: v.number() + funcaoGratificada: v.number(), + totalCadastros: v.number() }), handler: async (ctx) => { // Contar funcionários @@ -36,41 +38,22 @@ export const getStats = query({ const simbolos = await ctx.db.query('simbolos').collect(); const totalSimbolos = simbolos.length; + // Contar usuários cadastrados + const usuarios = await ctx.db.query('usuarios').collect(); + const totalUsuarios = usuarios.length; + + // Calcular total de cadastros (funcionários + símbolos + usuários) + const totalCadastros = totalFuncionarios + totalSimbolos + totalUsuarios; + return { totalFuncionarios, totalSimbolos, + totalUsuarios, funcionariosAtivos, funcionariosDesligados, cargoComissionado, - funcaoGratificada - }; - } -}); - -// Obter atividades recentes (últimas 24 horas) -export const getRecentActivity = query({ - args: {}, - returns: v.object({ - funcionariosCadastrados24h: v.number(), - simbolosCadastrados24h: v.number() - }), - handler: async (ctx) => { - const now = Date.now(); - const last24h = now - 24 * 60 * 60 * 1000; - - // Funcionários cadastrados nas últimas 24h - const funcionarios = await ctx.db.query('funcionarios').collect(); - const funcionariosCadastrados24h = funcionarios.filter( - (f) => f._creationTime >= last24h - ).length; - - // Símbolos cadastrados nas últimas 24h - const simbolos = await ctx.db.query('simbolos').collect(); - const simbolosCadastrados24h = simbolos.filter((s) => s._creationTime >= last24h).length; - - return { - funcionariosCadastrados24h, - simbolosCadastrados24h + funcaoGratificada, + totalCadastros }; } }); diff --git a/packages/backend/convex/monitoramento.ts b/packages/backend/convex/monitoramento.ts index dd6fefb..a76ea1b 100644 --- a/packages/backend/convex/monitoramento.ts +++ b/packages/backend/convex/monitoramento.ts @@ -847,224 +847,3 @@ export const obterHistoricoAlertas = query({ } }); -/** - * Status consolidado do sistema para o dashboard - */ -export const getStatusSistema = query({ - args: {}, - returns: v.object({ - usuariosOnline: v.number(), - totalRegistros: v.number(), - tempoMedioResposta: v.number(), - cpuUsada: v.number(), - memoriaUsada: v.number(), - ultimaAtualizacao: v.number() - }), - handler: async (ctx) => { - try { - // Última métrica, se existir - const ultimaMetrica = (await ctx.db.query('systemMetrics').order('desc').first()) ?? null; - - // Usuários online: usar métrica se disponível, senão derivar de usuários - let usuariosOnline = 0; - if (ultimaMetrica?.usuariosOnline !== undefined) { - usuariosOnline = ultimaMetrica.usuariosOnline; - } else { - const usuarios = await ctx.db.query('usuarios').collect(); - usuariosOnline = usuarios.filter((u) => u.statusPresenca === 'online').length; - } - - // Total de registros (estimativa baseada em tabelas principais) - const [usuarios, funcionarios, simbolos, alertas, metricas] = await Promise.all([ - ctx.db.query('usuarios').collect(), - ctx.db.query('funcionarios').collect(), - ctx.db.query('simbolos').collect(), - ctx.db.query('alertConfigurations').collect(), - ctx.db.query('systemMetrics').take(100) // não precisa contar tudo - ]); - const totalRegistros = - usuarios.length + funcionarios.length + simbolos.length + alertas.length + metricas.length; - - // Métricas de performance com fallbacks seguros - const tempoMedioResposta = ultimaMetrica?.tempoRespostaMedio ?? 0; - const cpuUsada = Math.max( - 0, - Math.min(100, Math.round((ultimaMetrica?.cpuUsage ?? 0) * 100) / 100) - ); - const memoriaUsada = Math.max( - 0, - Math.min(100, Math.round((ultimaMetrica?.memoryUsage ?? 0) * 100) / 100) - ); - const ultimaAtualizacao = ultimaMetrica?.timestamp ?? Date.now(); - - return { - usuariosOnline, - totalRegistros, - tempoMedioResposta, - cpuUsada, - memoriaUsada, - ultimaAtualizacao - }; - } catch (error) { - console.error('Erro em getStatusSistema:', error); - // Retornar valores padrão em caso de erro - return { - usuariosOnline: 0, - totalRegistros: 0, - tempoMedioResposta: 0, - cpuUsada: 0, - memoriaUsada: 0, - ultimaAtualizacao: Date.now() - }; - } - } -}); - -/** - * Atividade do banco no último minuto (agregada em buckets) - * Usa logsAtividades e systemMetrics para calcular atividade real. - */ -export const getAtividadeBancoDados = query({ - args: {}, - returns: v.object({ - historico: v.array( - v.object({ - entradas: v.number(), - saidas: v.number() - }) - ) - }), - handler: async (ctx) => { - try { - const agora = Date.now(); - const haUmMinuto = agora - 60 * 1000; - - // Buscar atividades reais do sistema - const atividadesRecentes = await ctx.db - .query('logsAtividades') - .withIndex('by_timestamp', (q) => q.gte('timestamp', haUmMinuto)) - .order('asc') - .collect(); - - // Buscar métricas também (para mensagens se houver) - const metricasRecentes = await ctx.db - .query('systemMetrics') - .withIndex('by_timestamp', (q) => q.gte('timestamp', haUmMinuto)) - .order('asc') - .collect(); - - // Bucketizar em 30 pontos (~2s cada) para visualização - const numBuckets = 30; - const bucketSizeMs = Math.ceil(60_000 / numBuckets); - const historico: Array<{ entradas: number; saidas: number }> = []; - - for (let i = 0; i < numBuckets; i++) { - const inicio = haUmMinuto + i * bucketSizeMs; - const fim = inicio + bucketSizeMs; - - // Contar atividades de criação/inserção (entradas) - const atividadesBucket = atividadesRecentes.filter( - (a) => a.timestamp >= inicio && a.timestamp < fim - ); - const entradasAtividades = atividadesBucket.filter( - (a) => a.acao === 'criar' || a.acao === 'inserir' || a.acao === 'cadastrar' - ).length; - - // Contar atividades de exclusão/remoção (saídas) - const saidasAtividades = atividadesBucket.filter( - (a) => a.acao === 'excluir' || a.acao === 'remover' || a.acao === 'deletar' - ).length; - - // Usar mensagensPorMinuto como adicional se disponível - const bucketMetricas = metricasRecentes.filter( - (m) => m.timestamp >= inicio && m.timestamp < fim - ); - const somaMensagens = - bucketMetricas.reduce((acc, m) => acc + (m.mensagensPorMinuto ?? 0), 0) || 0; - - // Combinar atividades reais com métricas de mensagens - const entradas = Math.max(0, Math.round(entradasAtividades + somaMensagens * 0.3)); - const saidas = Math.max(0, Math.round(saidasAtividades + somaMensagens * 0.2)); - - historico.push({ entradas, saidas }); - } - - return { historico }; - } catch (error) { - console.error('Erro em getAtividadeBancoDados:', error); - // Retornar histórico vazio em caso de erro - return { historico: Array(30).fill({ entradas: 0, saidas: 0 }) }; - } - } -}); - -/** - * Distribuição de operações (calculada a partir de logsAtividades e métricas) - */ -export const getDistribuicaoRequisicoes = query({ - args: {}, - returns: v.object({ - queries: v.number(), - mutations: v.number(), - leituras: v.number(), - escritas: v.number() - }), - handler: async (ctx) => { - try { - const umaHoraAtras = Date.now() - 60 * 60 * 1000; - - // Buscar atividades reais do sistema - const atividades = await ctx.db - .query('logsAtividades') - .withIndex('by_timestamp', (q) => q.gte('timestamp', umaHoraAtras)) - .collect(); - - // Buscar métricas também - const metricas = await ctx.db - .query('systemMetrics') - .withIndex('by_timestamp', (q) => q.gte('timestamp', umaHoraAtras)) - .order('desc') - .take(100); - - // Contar operações de leitura (consultas, visualizações) - const leituras = atividades.filter( - (a) => - a.acao === 'consultar' || - a.acao === 'visualizar' || - a.acao === 'listar' || - a.acao === 'buscar' - ).length; - - // Contar operações de escrita (criar, editar, excluir) - const escritas = atividades.filter( - (a) => - a.acao === 'criar' || - a.acao === 'editar' || - a.acao === 'excluir' || - a.acao === 'inserir' || - a.acao === 'atualizar' || - a.acao === 'deletar' || - a.acao === 'cadastrar' || - a.acao === 'remover' - ).length; - - // Adicionar estimativa baseada em mensagens se disponível - const totalMensagens = Math.max( - 0, - Math.round(metricas.reduce((acc, m) => acc + (m.mensagensPorMinuto ?? 0), 0)) - ); - - // Queries são leituras + parte das mensagens (como consultas de chat) - const queries = leituras + Math.round(totalMensagens * 0.5); - - // Mutations são escritas + parte das mensagens (como envio de mensagens) - const mutations = escritas + Math.round(totalMensagens * 0.3); - - return { queries, mutations, leituras, escritas }; - } catch (error) { - console.error('Erro em getDistribuicaoRequisicoes:', error); - // Retornar valores padrão em caso de erro - return { queries: 0, mutations: 0, leituras: 0, escritas: 0 }; - } - } -}); diff --git a/packages/backend/convex/pontos.ts b/packages/backend/convex/pontos.ts index b23f8b1..f43d187 100644 --- a/packages/backend/convex/pontos.ts +++ b/packages/backend/convex/pontos.ts @@ -636,45 +636,59 @@ export const registrarPonto = mutation({ .filter((q) => q.eq(q.field('ativo'), true)) .collect(); - const dataConsulta = new Date(data); + // Helper para criar timestamp UTC a partir de data (YYYY-MM-DD), hora e minuto em GMT-3 + // A hora informada está em GMT-3, então precisamos adicionar 3 horas para obter UTC + const offsetGMT3ParaUTC = 3 * 60 * 60 * 1000; // 3 horas em milissegundos + function criarTimestampUTCDeGMT3(data: string, hora: number, minuto: number): number { + const [ano, mes, dia] = data.split('-').map(Number); + return Date.UTC(ano, mes - 1, dia, hora, minuto, 0, 0) + offsetGMT3ParaUTC; + } + + // Helper para criar timestamp UTC a partir de data (YYYY-MM-DD), hora e minuto que já estão em UTC + function criarTimestampUTC(data: string, horaUTC: number, minutoUTC: number): number { + const [ano, mes, dia] = data.split('-').map(Number); + return Date.UTC(ano, mes - 1, dia, horaUTC, minutoUTC, 0, 0); + } + + // Obter timestamp atual em UTC + const agoraUTC = new Date(); + const agoraTimestampUTC = agoraUTC.getTime(); + + // Timestamp da consulta (registro sendo feito) em UTC + // hora/minuto já estão em UTC (extraídos com getUTCHours/getUTCMinutes) + const timestampConsultaUTC = criarTimestampUTC(data, hora, minuto); + for (const dispensa of dispensas) { // Se for isento, sempre está dispensado if (dispensa.isento) { throw new Error('Registro dispensado pelo gestor: Isento de registro (caso excepcional)'); } - // Verificar se está no período - const dataInicio = new Date(dispensa.dataInicio); - const dataFim = new Date(dispensa.dataFim); + // Calcular timestamps de início e fim da dispensa em UTC + const timestampInicioUTC = criarTimestampUTCDeGMT3( + dispensa.dataInicio, + dispensa.horaInicio, + dispensa.minutoInicio + ); + const timestampFimUTC = criarTimestampUTCDeGMT3( + dispensa.dataFim, + dispensa.horaFim, + dispensa.minutoFim + ); - if (dataConsulta >= dataInicio && dataConsulta <= dataFim) { - // Verificar hora e minuto se necessário - const timestampConsulta = new Date( - `${data}T${hora.toString().padStart(2, '0')}:${minuto.toString().padStart(2, '0')}:00` - ).getTime(); - const timestampInicio = new Date( - `${dispensa.dataInicio}T${dispensa.horaInicio.toString().padStart(2, '0')}:${dispensa.minutoInicio.toString().padStart(2, '0')}:00` - ).getTime(); - const timestampFim = new Date( - `${dispensa.dataFim}T${dispensa.horaFim.toString().padStart(2, '0')}:${dispensa.minutoFim.toString().padStart(2, '0')}:00` - ).getTime(); - - if (timestampConsulta >= timestampInicio && timestampConsulta <= timestampFim) { - throw new Error(`Registro dispensado pelo gestor: ${dispensa.motivo}`); - } - } - - // Verificar se expirou (desativar na mutation de registro) - const agora = new Date(); - const dataFimTimestamp = new Date( - `${dispensa.dataFim}T${dispensa.horaFim.toString().padStart(2, '0')}:${dispensa.minutoFim.toString().padStart(2, '0')}:00` - ).getTime(); - - if (agora.getTime() > dataFimTimestamp && !dispensa.isento) { - // Desativar dispensa expirada (mutation pode fazer isso) + // Desativar dispensa expirada ANTES de verificar bloqueio (após o fim) + // Verificar se AGORA já passou do horário de fim da dispensa + if (agoraTimestampUTC > timestampFimUTC) { await ctx.db.patch(dispensa._id, { ativo: false }); + continue; // Pular verificação de bloqueio se já expirou + } + + // Verificar se AGORA está dentro do período da dispensa (não o horário do registro) + // Se o momento atual está dentro do período, bloqueia qualquer tentativa de registro + if (agoraTimestampUTC >= timestampInicioUTC && agoraTimestampUTC <= timestampFimUTC) { + throw new Error(`Registro dispensado pelo gestor: ${dispensa.motivo}`); } } @@ -2883,10 +2897,7 @@ export const excluirHomologacao = mutation({ }; // Se a homologação tem valores anteriores, restaurar - if ( - homologacao.horaAnterior !== undefined && - homologacao.minutoAnterior !== undefined - ) { + if (homologacao.horaAnterior !== undefined && homologacao.minutoAnterior !== undefined) { patchData.hora = homologacao.horaAnterior; patchData.minuto = homologacao.minutoAnterior; } @@ -3033,10 +3044,8 @@ export const removerDispensaRegistro = mutation({ throw new Error('Você não tem permissão para remover esta dispensa'); } - // Desativar dispensa - await ctx.db.patch(args.dispensaId, { - ativo: false - }); + // Deletar dispensa do banco de dados + await ctx.db.delete(args.dispensaId); return { success: true }; } @@ -3117,14 +3126,49 @@ export const listarDispensas = query({ } } - // Verificar se expirou (se não for isento) + // Verificar se está ativa ou expirada (considerando data, hora e minuto em GMT-3) let expirada = false; + + // GMT-3 está 3 horas ATRÁS do UTC + // Offset: +3 horas para converter GMT-3 para UTC + const offsetGMT3ParaUTC = 3 * 60 * 60 * 1000; // 3 horas em milissegundos + + // Obter data/hora atual em UTC + const agoraUTC = new Date(); + const agoraTimestampUTC = agoraUTC.getTime(); + + // Helper para criar timestamp UTC a partir de data (YYYY-MM-DD), hora e minuto em GMT-3 + // A hora informada está em GMT-3, então precisamos adicionar 3 horas para obter UTC + // Exemplo: 08:00 GMT-3 = 11:00 UTC + function criarTimestampUTCDeGMT3(data: string, hora: number, minuto: number): number { + const [ano, mes, dia] = data.split('-').map(Number); + // Date.UTC cria timestamp UTC + // Se a hora está em GMT-3, adicionamos 3 horas para obter o equivalente UTC + return Date.UTC(ano, mes - 1, dia, hora, minuto, 0, 0) + offsetGMT3ParaUTC; + } + if (!d.isento) { - const agora = new Date(); - const dataFimTimestamp = new Date( - `${d.dataFim}T${d.horaFim.toString().padStart(2, '0')}:${d.minutoFim.toString().padStart(2, '0')}:00` - ).getTime(); - expirada = agora.getTime() > dataFimTimestamp; + // Para dispensas não isentas, verificar se está dentro do período + const dataInicioTimestamp = criarTimestampUTCDeGMT3( + d.dataInicio, + d.horaInicio, + d.minutoInicio + ); + const dataFimTimestamp = criarTimestampUTCDeGMT3(d.dataFim, d.horaFim, d.minutoFim); + + // Está expirada se estiver antes do início OU depois do fim + // Está ativa se: dataInicioTimestamp <= agoraTimestampUTC <= dataFimTimestamp + expirada = + agoraTimestampUTC < dataInicioTimestamp || agoraTimestampUTC > dataFimTimestamp; + } else { + // Se for isento, verificar apenas se já passou do início + const dataInicioTimestamp = criarTimestampUTCDeGMT3( + d.dataInicio, + d.horaInicio, + d.minutoInicio + ); + // Se ainda não começou, está expirada (não ativa ainda) + expirada = agoraTimestampUTC < dataInicioTimestamp; } return { @@ -3349,7 +3393,16 @@ export const verificarDispensaAtiva = query({ .filter((q) => q.eq(q.field('ativo'), true)) .collect(); - const dataConsulta = new Date(args.data); + // Helper para criar timestamp UTC a partir de data (YYYY-MM-DD), hora e minuto em GMT-3 + const offsetGMT3ParaUTC = 3 * 60 * 60 * 1000; // 3 horas em milissegundos + function criarTimestampUTCDeGMT3(data: string, hora: number, minuto: number): number { + const [ano, mes, dia] = data.split('-').map(Number); + return Date.UTC(ano, mes - 1, dia, hora, minuto, 0, 0) + offsetGMT3ParaUTC; + } + + // Obter timestamp atual em UTC + const agoraUTC = new Date(); + const agoraTimestampUTC = agoraUTC.getTime(); for (const dispensa of dispensas) { // Se for isento, sempre está dispensado @@ -3361,33 +3414,39 @@ export const verificarDispensaAtiva = query({ }; } - // Verificar se está no período - const dataInicio = new Date(dispensa.dataInicio); - const dataFim = new Date(dispensa.dataFim); + // Calcular timestamps de início e fim da dispensa em UTC + const timestampInicioUTC = criarTimestampUTCDeGMT3( + dispensa.dataInicio, + dispensa.horaInicio, + dispensa.minutoInicio + ); + const timestampFimUTC = criarTimestampUTCDeGMT3( + dispensa.dataFim, + dispensa.horaFim, + dispensa.minutoFim + ); - // Se a data está dentro do período - if (dataConsulta >= dataInicio && dataConsulta <= dataFim) { - // Se hora e minuto foram fornecidos, verificar também - if (args.hora !== undefined && args.minuto !== undefined) { - const timestampConsulta = new Date( - `${args.data}T${args.hora.toString().padStart(2, '0')}:${args.minuto.toString().padStart(2, '0')}:00` - ).getTime(); - const timestampInicio = new Date( - `${dispensa.dataInicio}T${dispensa.horaInicio.toString().padStart(2, '0')}:${dispensa.minutoInicio.toString().padStart(2, '0')}:00` - ).getTime(); - const timestampFim = new Date( - `${dispensa.dataFim}T${dispensa.horaFim.toString().padStart(2, '0')}:${dispensa.minutoFim.toString().padStart(2, '0')}:00` - ).getTime(); + // Verificar se AGORA já passou do horário de fim da dispensa + // Se já expirou, não está mais dispensado + if (agoraTimestampUTC > timestampFimUTC) { + // Dispensa expirada, continuar para próxima + continue; + } - if (timestampConsulta >= timestampInicio && timestampConsulta <= timestampFim) { - return { - dispensado: true, - dispensa, - motivo: dispensa.motivo - }; - } - } else { - // Apenas verificar data + // Se hora e minuto foram fornecidos, verificar timestamp completo + if (args.hora !== undefined && args.minuto !== undefined) { + const timestampConsultaUTC = criarTimestampUTCDeGMT3(args.data, args.hora, args.minuto); + if (timestampConsultaUTC >= timestampInicioUTC && timestampConsultaUTC <= timestampFimUTC) { + return { + dispensado: true, + dispensa, + motivo: dispensa.motivo + }; + } + } else { + // Se apenas data foi fornecida, verificar se AGORA está dentro do período + // (não apenas a data, mas também o horário) + if (agoraTimestampUTC >= timestampInicioUTC && agoraTimestampUTC <= timestampFimUTC) { return { dispensado: true, dispensa, -- 2.49.1 From bdc0afccb87fd3835a1af1adec1b16ff9a127093 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Wed, 24 Dec 2025 08:36:44 -0300 Subject: [PATCH 09/12] feat: implement logic to remove hour adjustments upon homologation deletion, ensuring accurate recalculation of work hours and maintaining data integrity --- packages/backend/convex/pontos.ts | 107 ++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/packages/backend/convex/pontos.ts b/packages/backend/convex/pontos.ts index f43d187..21f15e7 100644 --- a/packages/backend/convex/pontos.ts +++ b/packages/backend/convex/pontos.ts @@ -2916,6 +2916,113 @@ export const excluirHomologacao = mutation({ } } + // Se for um ajuste de banco de horas, remover completamente do banco de dados + if (homologacao.tipoAjuste && homologacao.ajusteMinutos !== undefined) { + // Converter criadoEm da homologação para data (YYYY-MM-DD) + const dataHomologacao = new Date(homologacao.criadoEm).toISOString().split('T')[0]!; + + // Buscar o ajuste correspondente + // Procurar ajustes manuais do mesmo funcionário, gestor, tipo, valor e data + const ajustes = await ctx.db + .query('ajustesBancoHoras') + .withIndex('by_funcionario_data', (q) => + q.eq('funcionarioId', homologacao.funcionarioId).eq('dataAplicacao', dataHomologacao) + ) + .filter((q) => + q.and( + q.eq(q.field('motivoTipo'), 'manual'), + q.eq(q.field('tipo'), homologacao.tipoAjuste), + q.eq(q.field('valorMinutos'), homologacao.ajusteMinutos), + q.eq(q.field('gestorId'), homologacao.gestorId) + ) + ) + .collect(); + + // Se encontrou ajuste(s), encontrar o mais próximo em tempo à homologação + if (ajustes.length > 0) { + // Encontrar o ajuste com timestamp mais próximo ao da homologação + // (o ajuste geralmente é criado um pouco antes da homologação) + let ajusteMaisProximo = ajustes[0]!; + let menorDiferenca = Math.abs(ajustes[0]!.criadoEm - homologacao.criadoEm); + for (const ajusteCandidato of ajustes) { + const diferenca = Math.abs(ajusteCandidato.criadoEm - homologacao.criadoEm); + if (diferenca < menorDiferenca) { + menorDiferenca = diferenca; + ajusteMaisProximo = ajusteCandidato; + } + } + const ajuste = ajusteMaisProximo; + + // Buscar o banco de horas do dia onde o ajuste foi aplicado + const bancoHoras = await ctx.db + .query('bancoHoras') + .withIndex('by_funcionario_data', (q) => + q.eq('funcionarioId', homologacao.funcionarioId).eq('data', ajuste.dataAplicacao) + ) + .first(); + + if (bancoHoras) { + // Remover o ajuste do array ajustesIds + const novosAjustesIds = (bancoHoras.ajustesIds || []).filter( + (id) => id !== ajuste._id + ); + + // Reverter o ajuste do saldo (subtrair o valor que foi adicionado) + const novoSaldoMinutos = bancoHoras.saldoMinutos - ajuste.valorMinutos; + + // Verificar se ainda há outros ajustes ou se precisa resetar tipoDia + let novoTipoDia = bancoHoras.tipoDia; + if (novosAjustesIds.length > 0) { + // Se ainda há outros ajustes, verificar qual tipoDia deve ser mantido + const outrosAjustes = await Promise.all( + novosAjustesIds.map((id) => ctx.db.get(id)) + ); + const temAjusteAbonar = outrosAjustes.some((a) => a?.tipo === 'abonar'); + const temAjusteDescontar = outrosAjustes.some((a) => a?.tipo === 'descontar'); + + // Se há ajuste de abonar, manter ou definir como 'abonado' + if (temAjusteAbonar) { + novoTipoDia = 'abonado'; + } else if (temAjusteDescontar) { + // Se há ajuste de descontar, manter ou definir como 'descontado' + novoTipoDia = 'descontado'; + } else { + // Se não há ajustes que determinem tipoDia, resetar + novoTipoDia = undefined; + } + } else { + // Se não há mais ajustes, verificar se deve resetar tipoDia + // Se o tipoDia estava relacionado ao ajuste removido, resetar + if ( + (bancoHoras.tipoDia === 'abonado' && ajuste.tipo === 'abonar') || + (bancoHoras.tipoDia === 'descontado' && ajuste.tipo === 'descontar') + ) { + novoTipoDia = undefined; + } + } + + // Atualizar banco de horas + await ctx.db.patch(bancoHoras._id, { + saldoMinutos: novoSaldoMinutos, + ajustesIds: novosAjustesIds.length > 0 ? novosAjustesIds : undefined, + tipoDia: novoTipoDia + }); + + // Recalcular banco de horas mensal após remover ajuste + const mes = ajuste.dataAplicacao.substring(0, 7); // YYYY-MM + const hojeDate = new Date(); + const mesAtual = `${hojeDate.getFullYear()}-${String(hojeDate.getMonth() + 1).padStart(2, '0')}`; + const estaRemovendoMesPassado = mes < mesAtual; + + // Recalcular em cascata se for mês passado + await calcularBancoHorasMensal(ctx, homologacao.funcionarioId, mes, estaRemovendoMesPassado); + } + + // Excluir o registro de ajuste do banco de dados + await ctx.db.delete(ajuste._id); + } + } + // Excluir homologação await ctx.db.delete(args.homologacaoId); -- 2.49.1 From c7a64eb116a69a4201c3c67eeac16317fe56ec39 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Wed, 24 Dec 2025 10:41:12 -0300 Subject: [PATCH 10/12] feat: add period adjustment fields to point processing and PDF generation, enhancing data capture and display for time adjustments --- .../web/src/lib/utils/ponto/pdf/geradorPDF.ts | 32 ++++++- apps/web/src/lib/utils/ponto/processamento.ts | 8 +- apps/web/src/lib/utils/ponto/tipos.ts | 7 ++ .../controle-ponto/homologacao/+page.svelte | 59 +++++++++--- packages/backend/convex/pontos.ts | 90 +++++++++++++++++-- packages/backend/convex/tables/ponto.ts | 7 ++ 6 files changed, 182 insertions(+), 21 deletions(-) diff --git a/apps/web/src/lib/utils/ponto/pdf/geradorPDF.ts b/apps/web/src/lib/utils/ponto/pdf/geradorPDF.ts index a87a0f0..48066c6 100644 --- a/apps/web/src/lib/utils/ponto/pdf/geradorPDF.ts +++ b/apps/web/src/lib/utils/ponto/pdf/geradorPDF.ts @@ -541,20 +541,48 @@ function gerarSecaoAjustesPDF(doc: jsPDF, yPosition: number, dias: DiaFichaPonto doc.setTextColor(0, 0, 0); yPosition += 10; + // Função auxiliar para formatar período do ajuste + const formatarPeriodoAjuste = (ajuste: (typeof todosAjustes)[number]): string => { + if ( + ajuste.dataInicio && + ajuste.horaInicio !== undefined && + ajuste.minutoInicio !== undefined && + ajuste.dataFim && + ajuste.horaFim !== undefined && + ajuste.minutoFim !== undefined + ) { + const inicioStr = `${formatarDataDDMMAAAA(ajuste.dataInicio)} ${formatarHoraPonto( + ajuste.horaInicio, + ajuste.minutoInicio + )}`; + const fimStr = `${formatarDataDDMMAAAA(ajuste.dataFim)} ${formatarHoraPonto( + ajuste.horaFim, + ajuste.minutoFim + )}`; + return `${inicioStr} a ${fimStr}`; + } + // Fallback para ajustes antigos sem período + return formatarDataDDMMAAAA(ajuste.data); + }; + const ajustesData = todosAjustes.map((ajuste) => [ formatarDataDDMMAAAA(ajuste.data), ajuste.tipo === 'abonar' ? 'Abonar' : ajuste.tipo === 'descontar' ? 'Descontar' : 'Compensar', formatarMinutos(ajuste.valorMinutos), + formatarPeriodoAjuste(ajuste), ajuste.motivoDescricao || '-' ]); autoTable(doc, { startY: yPosition, - head: [['Data', 'Tipo', 'Valor', 'Motivo']], + head: [['Data Aplicação', 'Tipo', 'Valor', 'Período', 'Motivo']], body: ajustesData, theme: 'grid', headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' }, - styles: { fontSize: 9 } + styles: { fontSize: 9 }, + columnStyles: { + 3: { cellWidth: 'auto', minCellWidth: 60 } // Coluna de período com largura maior + } }); type JsPDFWithAutoTable = jsPDF & { diff --git a/apps/web/src/lib/utils/ponto/processamento.ts b/apps/web/src/lib/utils/ponto/processamento.ts index 8f40993..09daea2 100644 --- a/apps/web/src/lib/utils/ponto/processamento.ts +++ b/apps/web/src/lib/utils/ponto/processamento.ts @@ -598,7 +598,13 @@ export async function processarDadosFichaPonto( tipo: a.tipo, valorMinutos: a.valorMinutos, motivoDescricao: a.motivoDescricao, - gestorId: a.gestorId + gestorId: a.gestorId, + dataInicio: a.dataInicio, + horaInicio: a.horaInicio, + minutoInicio: a.minutoInicio, + dataFim: a.dataFim, + horaFim: a.horaFim, + minutoFim: a.minutoFim })), inconsistencias: inconsistenciasDia.map((i) => ({ _id: i._id, diff --git a/apps/web/src/lib/utils/ponto/tipos.ts b/apps/web/src/lib/utils/ponto/tipos.ts index a70e86d..5895911 100644 --- a/apps/web/src/lib/utils/ponto/tipos.ts +++ b/apps/web/src/lib/utils/ponto/tipos.ts @@ -60,6 +60,13 @@ export interface DiaFichaPonto { valorMinutos: number; motivoDescricao?: string; gestorId?: Id<'usuarios'>; + // Período do ajuste + dataInicio?: string; // YYYY-MM-DD + horaInicio?: number; // 0-23 + minutoInicio?: number; // 0-59 + dataFim?: string; // YYYY-MM-DD + horaFim?: number; // 0-23 + minutoFim?: number; // 0-59 }>; inconsistencias: Array<{ _id: Id<'inconsistenciasBancoHoras'>; diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/controle-ponto/homologacao/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/controle-ponto/homologacao/+page.svelte index 40ca808..812df10 100644 --- a/apps/web/src/routes/(dashboard)/recursos-humanos/controle-ponto/homologacao/+page.svelte +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/controle-ponto/homologacao/+page.svelte @@ -264,6 +264,10 @@ return; } + // Converter hora formato HH:mm para hora e minuto + const { hora: horaInicio, minuto: minutoInicio } = timeParaHoraMinuto(horaInicioAjuste); + const { hora: horaFim, minuto: minutoFim } = timeParaHoraMinuto(horaFimAjuste); + try { await client.mutation(api.pontos.ajustarBancoHoras, { funcionarioId: funcionarioSelecionado, @@ -271,6 +275,13 @@ periodoDias: dias, periodoHoras: horas, periodoMinutos: minutos, + dataAplicacao: dataInicioAjuste, // Data escolhida pelo usuário + dataInicio: dataInicioAjuste, + horaInicio, + minutoInicio, + dataFim: dataFimAjuste, + horaFim, + minutoFim, motivoId: motivoId || undefined, motivoTipo: motivoTipo || undefined, motivoDescricao: motivoDescricao || undefined, @@ -910,7 +921,11 @@ {#each homologacoes as homologacao (homologacao._id)} - {new Date(homologacao.criadoEm).toLocaleDateString('pt-BR')} + {#if homologacao.dataAplicacaoAjuste} + {new Date(homologacao.dataAplicacaoAjuste + 'T00:00:00').toLocaleDateString('pt-BR')} + {:else} + {new Date(homologacao.criadoEm).toLocaleDateString('pt-BR')} + {/if} {#if !funcionarioSelecionado} @@ -958,9 +973,16 @@
{:else if homologacao.ajusteMinutos} -
- {homologacao.periodoDias || 0}d {homologacao.periodoHoras || 0}h - {homologacao.periodoMinutos || 0}min +
+
+ {homologacao.periodoDias || 0}d {homologacao.periodoHoras || 0}h + {homologacao.periodoMinutos || 0}min +
+ {#if homologacao.periodoAjuste?.dataInicio && homologacao.periodoAjuste?.horaInicio !== undefined && homologacao.periodoAjuste?.minutoInicio !== undefined && homologacao.periodoAjuste?.dataFim && homologacao.periodoAjuste?.horaFim !== undefined && homologacao.periodoAjuste?.minutoFim !== undefined} +
+ {new Date(homologacao.periodoAjuste.dataInicio + 'T00:00:00').toLocaleDateString('pt-BR')} {formatarHoraPonto(homologacao.periodoAjuste.horaInicio, homologacao.periodoAjuste.minutoInicio)} a {new Date(homologacao.periodoAjuste.dataFim + 'T00:00:00').toLocaleDateString('pt-BR')} {formatarHoraPonto(homologacao.periodoAjuste.horaFim, homologacao.periodoAjuste.minutoFim)} +
+ {/if}
{/if} @@ -1026,13 +1048,18 @@
Data:  - {new Date(homologacaoSelecionada.criadoEm).toLocaleDateString('pt-BR', { - day: '2-digit', - month: '2-digit', - year: 'numeric', - hour: '2-digit', - minute: '2-digit' - })} + {#if homologacaoSelecionada.dataAplicacaoAjuste} + {new Date(homologacaoSelecionada.dataAplicacaoAjuste + 'T00:00:00').toLocaleDateString('pt-BR')} + (Aplicado em) + {:else} + {new Date(homologacaoSelecionada.criadoEm).toLocaleDateString('pt-BR', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + })} + {/if}
Funcionário: @@ -1124,6 +1151,16 @@ {homologacaoSelecionada.periodoHoras || 0}h {homologacaoSelecionada.periodoMinutos || 0}min
+ {#if homologacaoSelecionada.periodoAjuste?.dataInicio && homologacaoSelecionada.periodoAjuste?.horaInicio !== undefined && homologacaoSelecionada.periodoAjuste?.minutoInicio !== undefined && homologacaoSelecionada.periodoAjuste?.dataFim && homologacaoSelecionada.periodoAjuste?.horaFim !== undefined && homologacaoSelecionada.periodoAjuste?.minutoFim !== undefined} +
+ Data/Hora Início:  + {new Date(homologacaoSelecionada.periodoAjuste.dataInicio + 'T00:00:00').toLocaleDateString('pt-BR')} {formatarHoraPonto(homologacaoSelecionada.periodoAjuste.horaInicio, homologacaoSelecionada.periodoAjuste.minutoInicio)} +
+
+ Data/Hora Fim:  + {new Date(homologacaoSelecionada.periodoAjuste.dataFim + 'T00:00:00').toLocaleDateString('pt-BR')} {formatarHoraPonto(homologacaoSelecionada.periodoAjuste.horaFim, homologacaoSelecionada.periodoAjuste.minutoFim)} +
+ {/if} {#if homologacaoSelecionada.ajusteMinutos}
Ajuste Total:  diff --git a/packages/backend/convex/pontos.ts b/packages/backend/convex/pontos.ts index 21f15e7..057a930 100644 --- a/packages/backend/convex/pontos.ts +++ b/packages/backend/convex/pontos.ts @@ -2618,6 +2618,14 @@ export const ajustarBancoHoras = mutation({ periodoDias: v.number(), periodoHoras: v.number(), periodoMinutos: v.number(), + dataAplicacao: v.string(), // YYYY-MM-DD - Data em que o ajuste deve ser aplicado + // Período do ajuste (data/hora início e fim) + dataInicio: v.optional(v.string()), // YYYY-MM-DD + horaInicio: v.optional(v.number()), // 0-23 + minutoInicio: v.optional(v.number()), // 0-59 + dataFim: v.optional(v.string()), // YYYY-MM-DD + horaFim: v.optional(v.number()), // 0-23 + minutoFim: v.optional(v.number()), // 0-59 motivoId: v.optional(v.string()), motivoTipo: v.optional(v.string()), motivoDescricao: v.optional(v.string()), @@ -2655,8 +2663,8 @@ export const ajustarBancoHoras = mutation({ ajusteFinal = -ajusteMinutos; } - // Buscar banco de horas mais recente ou criar um registro de ajuste - const hoje = new Date().toISOString().split('T')[0]!; + // Usar a data de aplicação fornecida pelo usuário + const dataAplicacao = args.dataAplicacao; // Criar registro de ajuste na nova tabela const ajusteId = await ctx.db.insert('ajustesBancoHoras', { @@ -2666,7 +2674,13 @@ export const ajustarBancoHoras = mutation({ motivoId: args.motivoId, motivoDescricao: args.motivoDescricao || `Ajuste ${args.tipoAjuste}`, valorMinutos: ajusteFinal, - dataAplicacao: hoje, + dataAplicacao: dataAplicacao, + dataInicio: args.dataInicio, + horaInicio: args.horaInicio, + minutoInicio: args.minutoInicio, + dataFim: args.dataFim, + horaFim: args.horaFim, + minutoFim: args.minutoFim, gestorId: usuario._id, observacoes: args.observacoes, aplicado: false, @@ -2676,7 +2690,7 @@ export const ajustarBancoHoras = mutation({ const bancoHorasAtual = await ctx.db .query('bancoHoras') .withIndex('by_funcionario_data', (q) => - q.eq('funcionarioId', args.funcionarioId).eq('data', hoje) + q.eq('funcionarioId', args.funcionarioId).eq('data', dataAplicacao) ) .first(); @@ -2708,7 +2722,7 @@ export const ajustarBancoHoras = mutation({ await ctx.db.insert('bancoHoras', { funcionarioId: args.funcionarioId, - data: hoje, + data: dataAplicacao, cargaHorariaDiaria, horasTrabalhadas: 0, saldoMinutos: ajusteFinal, @@ -2727,7 +2741,7 @@ export const ajustarBancoHoras = mutation({ }); // Recalcular banco de horas mensal após ajuste - const mes = hoje.substring(0, 7); // YYYY-MM + const mes = dataAplicacao.substring(0, 7); // YYYY-MM // Verificar se estamos ajustando um mês passado const hojeDate = new Date(); @@ -2738,6 +2752,7 @@ export const ajustarBancoHoras = mutation({ await calcularBancoHorasMensal(ctx, args.funcionarioId, mes, estaAjustandoMesPassado); // Criar registro de homologação (mantido para compatibilidade) + // Armazenar o ajusteId para facilitar a busca posterior const homologacaoId = await ctx.db.insert('homologacoesPonto', { funcionarioId: args.funcionarioId, gestorId: usuario._id, @@ -2753,6 +2768,9 @@ export const ajustarBancoHoras = mutation({ criadoEm: Date.now() }); + // Armazenar ajusteId na homologação usando um campo customizado ou buscar depois + // Por enquanto, vamos buscar o ajuste no listarHomologacoes usando os critérios + return { success: true, homologacaoId, ajusteId, ajusteMinutos: ajusteFinal }; } }); @@ -2820,6 +2838,62 @@ export const listarHomologacoes = query({ } } + // Buscar dataAplicacao e período do ajuste se for um ajuste de banco de horas + let dataAplicacaoAjuste: string | null = null; + let periodoAjuste: { + dataInicio?: string; + horaInicio?: number; + minutoInicio?: number; + dataFim?: string; + horaFim?: number; + minutoFim?: number; + } | null = null; + if (h.tipoAjuste && h.ajusteMinutos !== undefined) { + // Buscar ajustes criados próximo ao tempo da homologação (dentro de 5 minutos antes) + // O ajuste é criado logo antes da homologação em ajustarBancoHoras + const tempoLimite = h.criadoEm - 5 * 60 * 1000; // 5 minutos antes + + // Buscar todos os ajustes do funcionário com os mesmos critérios + // e criados próximo ao tempo da homologação + const ajustes = await ctx.db + .query('ajustesBancoHoras') + .withIndex('by_funcionario', (q) => q.eq('funcionarioId', h.funcionarioId)) + .filter((q) => + q.and( + q.eq(q.field('motivoTipo'), 'manual'), + q.eq(q.field('tipo'), h.tipoAjuste), + q.eq(q.field('valorMinutos'), h.ajusteMinutos), + q.eq(q.field('gestorId'), h.gestorId), + q.gte(q.field('criadoEm'), tempoLimite), + q.lte(q.field('criadoEm'), h.criadoEm) + ) + ) + .collect(); + + // Se encontrou ajuste(s), encontrar o mais próximo em tempo à homologação + if (ajustes.length > 0) { + // Encontrar o ajuste com timestamp mais próximo ao da homologação + let ajusteMaisProximo = ajustes[0]!; + let menorDiferenca = Math.abs(ajustes[0]!.criadoEm - h.criadoEm); + for (const ajusteCandidato of ajustes) { + const diferenca = Math.abs(ajusteCandidato.criadoEm - h.criadoEm); + if (diferenca < menorDiferenca) { + menorDiferenca = diferenca; + ajusteMaisProximo = ajusteCandidato; + } + } + dataAplicacaoAjuste = ajusteMaisProximo.dataAplicacao; + periodoAjuste = { + dataInicio: ajusteMaisProximo.dataInicio, + horaInicio: ajusteMaisProximo.horaInicio, + minutoInicio: ajusteMaisProximo.minutoInicio, + dataFim: ajusteMaisProximo.dataFim, + horaFim: ajusteMaisProximo.horaFim, + minutoFim: ajusteMaisProximo.minutoFim + }; + } + } + return { ...h, funcionario: funcionario @@ -2839,7 +2913,9 @@ export const listarHomologacoes = query({ data: registro.data, tipo: registro.tipo } - : null + : null, + dataAplicacaoAjuste, + periodoAjuste }; }) ); diff --git a/packages/backend/convex/tables/ponto.ts b/packages/backend/convex/tables/ponto.ts index d206f32..99f6e1d 100644 --- a/packages/backend/convex/tables/ponto.ts +++ b/packages/backend/convex/tables/ponto.ts @@ -368,6 +368,13 @@ export const pontoTables = { valorMinutos: v.number(), // Valor em minutos (positivo para abonar, negativo para descontar) // Data de aplicação dataAplicacao: v.string(), // YYYY-MM-DD + // Período do ajuste (data/hora início e fim) + dataInicio: v.optional(v.string()), // YYYY-MM-DD + horaInicio: v.optional(v.number()), // 0-23 + minutoInicio: v.optional(v.number()), // 0-59 + dataFim: v.optional(v.string()), // YYYY-MM-DD + horaFim: v.optional(v.number()), // 0-23 + minutoFim: v.optional(v.number()), // 0-59 // Gestor responsável (null se automático) gestorId: v.optional(v.id('usuarios')), // Observações -- 2.49.1 From 3ee405a0029d7d2325842e19f5b6281100ca5e09 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Wed, 24 Dec 2025 10:53:51 -0300 Subject: [PATCH 11/12] feat: add optional date and time fields for period adjustments in point processing, improving data capture for adjustments --- packages/backend/convex/pontos.ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/backend/convex/pontos.ts b/packages/backend/convex/pontos.ts index 057a930..873f89d 100644 --- a/packages/backend/convex/pontos.ts +++ b/packages/backend/convex/pontos.ts @@ -3765,7 +3765,13 @@ export const obterBancoHorasCompleto = query({ tipo: a.tipo, valorMinutos: a.valorMinutos, motivoDescricao: a.motivoDescricao, - motivoTipo: a.motivoTipo + motivoTipo: a.motivoTipo, + dataInicio: a.dataInicio, + horaInicio: a.horaInicio, + minutoInicio: a.minutoInicio, + dataFim: a.dataFim, + horaFim: a.horaFim, + minutoFim: a.minutoFim })), inconsistencias: inconsistenciasFiltradas.map((i) => ({ _id: i._id, @@ -3802,6 +3808,12 @@ export const listarAjustesBancoHoras = query({ ), motivoDescricao: v.optional(v.string()), dataAplicacao: v.string(), + dataInicio: v.optional(v.string()), + horaInicio: v.optional(v.number()), + minutoInicio: v.optional(v.number()), + dataFim: v.optional(v.string()), + horaFim: v.optional(v.number()), + minutoFim: v.optional(v.number()), aplicado: v.boolean(), gestor: v.union( v.object({ @@ -3856,6 +3868,12 @@ export const listarAjustesBancoHoras = query({ motivoTipo: ajuste.motivoTipo, motivoDescricao: ajuste.motivoDescricao, dataAplicacao: ajuste.dataAplicacao, + dataInicio: ajuste.dataInicio, + horaInicio: ajuste.horaInicio, + minutoInicio: ajuste.minutoInicio, + dataFim: ajuste.dataFim, + horaFim: ajuste.horaFim, + minutoFim: ajuste.minutoFim, aplicado: ajuste.aplicado, gestor: gestor ? { nome: gestor.nome } : null }; -- 2.49.1 From b965514e53c99d182ed250d0768a2cd768af14e1 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Wed, 24 Dec 2025 11:01:11 -0300 Subject: [PATCH 12/12] feat: refine homologation deletion logic to include time-based filtering for adjustments and ensure accurate recalculation of work hours, enhancing data integrity --- packages/backend/convex/pontos.ts | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/packages/backend/convex/pontos.ts b/packages/backend/convex/pontos.ts index 873f89d..9c3e8b3 100644 --- a/packages/backend/convex/pontos.ts +++ b/packages/backend/convex/pontos.ts @@ -2994,22 +2994,23 @@ export const excluirHomologacao = mutation({ // Se for um ajuste de banco de horas, remover completamente do banco de dados if (homologacao.tipoAjuste && homologacao.ajusteMinutos !== undefined) { - // Converter criadoEm da homologação para data (YYYY-MM-DD) - const dataHomologacao = new Date(homologacao.criadoEm).toISOString().split('T')[0]!; + // Buscar ajustes criados próximo ao tempo da homologação (dentro de 5 minutos antes) + // O ajuste é criado logo antes da homologação em ajustarBancoHoras + const tempoLimite = homologacao.criadoEm - 5 * 60 * 1000; // 5 minutos antes - // Buscar o ajuste correspondente - // Procurar ajustes manuais do mesmo funcionário, gestor, tipo, valor e data + // Buscar todos os ajustes do funcionário com os mesmos critérios + // e criados próximo ao tempo da homologação const ajustes = await ctx.db .query('ajustesBancoHoras') - .withIndex('by_funcionario_data', (q) => - q.eq('funcionarioId', homologacao.funcionarioId).eq('dataAplicacao', dataHomologacao) - ) + .withIndex('by_funcionario', (q) => q.eq('funcionarioId', homologacao.funcionarioId)) .filter((q) => q.and( q.eq(q.field('motivoTipo'), 'manual'), q.eq(q.field('tipo'), homologacao.tipoAjuste), q.eq(q.field('valorMinutos'), homologacao.ajusteMinutos), - q.eq(q.field('gestorId'), homologacao.gestorId) + q.eq(q.field('gestorId'), homologacao.gestorId), + q.gte(q.field('criadoEm'), tempoLimite), + q.lte(q.field('criadoEm'), homologacao.criadoEm) ) ) .collect(); @@ -3084,6 +3085,17 @@ export const excluirHomologacao = mutation({ tipoDia: novoTipoDia }); + // Recalcular banco de horas do dia específico para garantir consistência + const config = await ctx.db + .query('configuracaoPonto') + .withIndex('by_ativo', (q) => q.eq('ativo', true)) + .first(); + + if (config) { + // Recalcular banco de horas do dia para atualizar valores baseados nos registros + await atualizarBancoHoras(ctx, homologacao.funcionarioId, ajuste.dataAplicacao, config); + } + // Recalcular banco de horas mensal após remover ajuste const mes = ajuste.dataAplicacao.substring(0, 7); // YYYY-MM const hojeDate = new Date(); -- 2.49.1