From 66f995cb0873e0bd3c31514c62b2b1b2df06a092 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Fri, 5 Dec 2025 11:57:15 -0300 Subject: [PATCH] feat: implement date parsing utility across absence management components for improved date handling and consistency --- .../lib/components/AprovarAusencias.svelte | 9 +- .../ausencias/CalendarioAusencias.svelte | 5 +- .../WizardSolicitacaoAusencia.svelte | 15 +- .../components/ponto/BancoHorasMensal.svelte | 700 +++++++++++++++++ apps/web/src/lib/utils/datas.ts | 63 ++ .../gestao-ausencias/+page.svelte | 9 +- .../routes/(dashboard)/perfil/+page.svelte | 68 +- .../recursos-humanos/ausencias/+page.svelte | 9 +- .../controle-ponto/+page.svelte | 19 + .../controle-ponto/banco-horas/+page.svelte | 391 ++++++++++ packages/backend/convex/_generated/api.d.ts | 2 + packages/backend/convex/ausencias.ts | 49 +- packages/backend/convex/crons.ts | 8 + packages/backend/convex/pontos.ts | 711 +++++++++++++++++- packages/backend/convex/tables/ponto.ts | 17 + packages/backend/convex/utils/datas.ts | 65 ++ 16 files changed, 2053 insertions(+), 87 deletions(-) create mode 100644 apps/web/src/lib/components/ponto/BancoHorasMensal.svelte create mode 100644 apps/web/src/lib/utils/datas.ts create mode 100644 apps/web/src/routes/(dashboard)/recursos-humanos/controle-ponto/banco-horas/+page.svelte create mode 100644 packages/backend/convex/utils/datas.ts diff --git a/apps/web/src/lib/components/AprovarAusencias.svelte b/apps/web/src/lib/components/AprovarAusencias.svelte index 57b0386..f58e22d 100644 --- a/apps/web/src/lib/components/AprovarAusencias.svelte +++ b/apps/web/src/lib/components/AprovarAusencias.svelte @@ -5,6 +5,7 @@ import ErrorModal from './ErrorModal.svelte'; import UserAvatar from './chat/UserAvatar.svelte'; import { Calendar, FileText, XCircle, X, Check, Clock, User, Info } from 'lucide-svelte'; + import { parseLocalDate } from '$lib/utils/datas'; type SolicitacaoAusencia = Doc<'solicitacoesAusencias'> & { funcionario?: Doc<'funcionarios'> | null; @@ -30,8 +31,8 @@ let mensagemErroModal = $state(''); function calcularDias(dataInicio: string, dataFim: string): number { - const inicio = new Date(dataInicio); - const fim = new Date(dataFim); + const inicio = parseLocalDate(dataInicio); + const fim = parseLocalDate(dataFim); const diff = fim.getTime() - inicio.getTime(); return Math.ceil(diff / (1000 * 60 * 60 * 24)) + 1; } @@ -199,7 +200,7 @@ >
Data Início
- {new Date(solicitacao.dataInicio).toLocaleDateString('pt-BR')} + {parseLocalDate(solicitacao.dataInicio).toLocaleDateString('pt-BR')}
Data Fim
- {new Date(solicitacao.dataFim).toLocaleDateString('pt-BR')} + {parseLocalDate(solicitacao.dataFim).toLocaleDateString('pt-BR')}

Data Início

- {new Date(dataInicio).toLocaleDateString('pt-BR')} + {parseLocalDate(dataInicio).toLocaleDateString('pt-BR')}

Data Fim

- {new Date(dataFim).toLocaleDateString('pt-BR')} + {parseLocalDate(dataFim).toLocaleDateString('pt-BR')}

diff --git a/apps/web/src/lib/components/ausencias/WizardSolicitacaoAusencia.svelte b/apps/web/src/lib/components/ausencias/WizardSolicitacaoAusencia.svelte index 658edef..3e99d17 100644 --- a/apps/web/src/lib/components/ausencias/WizardSolicitacaoAusencia.svelte +++ b/apps/web/src/lib/components/ausencias/WizardSolicitacaoAusencia.svelte @@ -7,6 +7,7 @@ import type { Id } from '@sgse-app/backend/convex/_generated/dataModel'; import { SvelteDate } from 'svelte/reactivity'; import { Check, ChevronLeft, ChevronRight, Calendar, Info, AlertTriangle, X, CheckCircle } from 'lucide-svelte'; + import { parseLocalDate } from '$lib/utils/datas'; interface Props { funcionarioId: Id<'funcionarios'>; @@ -71,14 +72,14 @@ const hoje = new SvelteDate(); hoje.setHours(0, 0, 0, 0); - const inicio = new Date(dataInicio); + const inicio = parseLocalDate(dataInicio); if (inicio < hoje) { toast.error('A data de início não pode ser no passado'); return; } - if (new Date(dataFim) < new Date(dataInicio)) { + if (parseLocalDate(dataFim) < parseLocalDate(dataInicio)) { toast.error('A data de fim deve ser maior ou igual à data de início'); return; } @@ -134,7 +135,7 @@ mensagemErro.includes('solicitação aprovada ou pendente') ) { mensagemErroModal = 'Não é possível criar esta solicitação.'; - detalhesErroModal = `Já existe uma solicitação aprovada ou pendente para o período selecionado:\n\nPeríodo selecionado: ${new Date(dataInicio).toLocaleDateString('pt-BR')} até ${new Date(dataFim).toLocaleDateString('pt-BR')}\n\nPor favor, escolha um período diferente ou aguarde a resposta da solicitação existente.`; + detalhesErroModal = `Já existe uma solicitação aprovada ou pendente para o período selecionado:\n\nPeríodo selecionado: ${parseLocalDate(dataInicio).toLocaleDateString('pt-BR')} até ${parseLocalDate(dataFim).toLocaleDateString('pt-BR')}\n\nPor favor, escolha um período diferente ou aguarde a resposta da solicitação existente.`; mostrarModalErro = true; } else { // Outros erros continuam usando toast @@ -230,8 +231,8 @@

Período selecionado!

- De {new Date(dataInicio).toLocaleDateString('pt-BR')} até - {new Date(dataFim).toLocaleDateString('pt-BR')} ({totalDias} dias) + De {parseLocalDate(dataInicio).toLocaleDateString('pt-BR')} até + {parseLocalDate(dataFim).toLocaleDateString('pt-BR')} ({totalDias} dias)

@@ -259,13 +260,13 @@

Data Início

- {new Date(dataInicio).toLocaleDateString('pt-BR')} + {parseLocalDate(dataInicio).toLocaleDateString('pt-BR')}

Data Fim

- {new Date(dataFim).toLocaleDateString('pt-BR')} + {parseLocalDate(dataFim).toLocaleDateString('pt-BR')}

diff --git a/apps/web/src/lib/components/ponto/BancoHorasMensal.svelte b/apps/web/src/lib/components/ponto/BancoHorasMensal.svelte new file mode 100644 index 0000000..df03051 --- /dev/null +++ b/apps/web/src/lib/components/ponto/BancoHorasMensal.svelte @@ -0,0 +1,700 @@ + + +
+ +
+

Banco de Horas Mensal

+
+ +
+ + + {formatarMes(mesSelecionado)} + + +
+
+
+ + + {#if saldoNegativo && bancoMensal} +
+ +
+

Atenção: Saldo Negativo Acumulado

+
+ Seu saldo acumulado está negativo em{' '} + + {Math.abs(bancoMensal.saldoFormatado.final.horas)}h{' '} + {Math.abs(bancoMensal.saldoFormatado.final.minutos)}min + + . Considere compensar horas ou entrar em contato com seu gestor. +
+
+
+ {/if} + + {#if bancoMensalQuery?.isLoading} +
+ +
+ {:else if bancoMensal} + +
+ +
+
+
+
+

Saldo Inicial

+

+ {bancoMensal.saldoFormatado.inicial.positivo ? '+' : '-'} + {bancoMensal.saldoFormatado.inicial.horas}h{' '} + {bancoMensal.saldoFormatado.inicial.minutos}min +

+
+
+ {#if bancoMensal.saldoFormatado.inicial.positivo} + + {:else} + + {/if} +
+
+
+
+ + +
+
+
+
+

Saldo do Mês

+

+ {bancoMensal.saldoFormatado.mes.positivo ? '+' : '-'} + {bancoMensal.saldoFormatado.mes.horas}h{' '} + {bancoMensal.saldoFormatado.mes.minutos}min +

+
+
+ {#if bancoMensal.saldoFormatado.mes.positivo} + + {:else} + + {/if} +
+
+
+
+ + +
+
+
+
+

Saldo Final

+

+ {bancoMensal.saldoFormatado.final.positivo ? '+' : '-'} + {bancoMensal.saldoFormatado.final.horas}h{' '} + {bancoMensal.saldoFormatado.final.minutos}min +

+
+
+ {#if bancoMensal.saldoFormatado.final.positivo} + + {:else} + + {/if} +
+
+
+
+
+ + +
+ +
+
+
+
+ +
+
+

Horas Extras

+

+ {Math.floor(bancoMensal.horasExtras / 60)}h{' '} + {bancoMensal.horasExtras % 60}min +

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

Déficit de Horas

+

+ {Math.floor(bancoMensal.horasDeficit / 60)}h{' '} + {bancoMensal.horasDeficit % 60}min +

+
+
+
+
+
+ + +
+
+

+ + Informações do Mês +

+
+
+

Dias Trabalhados

+

{bancoMensal.diasTrabalhados} dias

+
+
+

Última Atualização

+

+ {new Date(bancoMensal.atualizadoEm).toLocaleDateString('pt-BR', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + })} +

+
+
+
+
+ {:else} +
+
+ +

Nenhum dado disponível para este mês

+

+ Não há registros de banco de horas para {formatarMes(mesSelecionado)}. +

+
+
+ {/if} + + + {#if chartData} +
+
+

+ + Evolução do Banco de Horas +

+
+ +
+
+
+ {/if} + + + {#if chartData} +
+
+

+ + Evolução do Banco de Horas +

+
+ +
+
+
+ {/if} + + + {#if historico && historico.length > 0} +
+
+

+ + Histórico dos Últimos 6 Meses +

+
+ + + + + + + + + + + + {#each historico as item} + + + + + + + + {/each} + +
MêsSaldo InicialSaldo do MêsSaldo FinalDias
{formatarMes(item.mes)} + + {item.saldoFormatado.inicial.positivo ? '+' : '-'} + {item.saldoFormatado.inicial.horas}h{' '} + {item.saldoFormatado.inicial.minutos}min + + + + {item.saldoFormatado.mes.positivo ? '+' : '-'} + {item.saldoFormatado.mes.horas}h{' '} + {item.saldoFormatado.mes.minutos}min + + + + {item.saldoFormatado.final.positivo ? '+' : '-'} + {item.saldoFormatado.final.horas}h{' '} + {item.saldoFormatado.final.minutos}min + + {item.diasTrabalhados}
+
+
+
+ {/if} + + + {#if historicoAlteracoes && historicoAlteracoes.length > 0} +
+
+

+ + Histórico de Alterações - {formatarMes(mesSelecionado)} +

+
+ {#each historicoAlteracoes as alteracao} +
+
+
+
+ {#if alteracao.tipoAlteracao === 'edicao_registro'} + + Edição de Registro + {:else if alteracao.tipoAlteracao === 'ajuste_banco'} + + + Ajuste de Banco de Horas + + {:else} + + Outro + {/if} + + {alteracao.dataFormatada} + +
+ + {#if alteracao.tipoAlteracao === 'edicao_registro' && alteracao.registro} +
+

+ Registro: {alteracao.registro.tipo} em{' '} + {alteracao.registro.data} +

+

+ Alteração:{' '} + + {alteracao.registro.horaAnterior} + {' '} + →{' '} + + {alteracao.registro.horaNova} + +

+ {#if alteracao.diferencaMinutos !== undefined} +

+ Diferença:{' '} + = 0 + ? 'text-success' + : 'text-error'} + > + {alteracao.diferencaMinutos >= 0 ? '+' : ''} + {Math.floor(Math.abs(alteracao.diferencaMinutos) / 60)}h{' '} + {Math.abs(alteracao.diferencaMinutos) % 60}min + +

+ {/if} +
+ {:else if alteracao.tipoAlteracao === 'ajuste_banco'} +
+

+ Tipo: {alteracao.tipoAjuste === 'compensar' + ? 'Compensar' + : alteracao.tipoAjuste === 'abonar' + ? 'Abonar' + : 'Descontar'} +

+ {#if alteracao.ajusteMinutos !== undefined} +

+ Ajuste:{' '} + = 0 + ? 'text-success' + : 'text-error'} + > + {alteracao.ajusteMinutos >= 0 ? '+' : ''} + {Math.floor(Math.abs(alteracao.ajusteMinutos) / 60)}h{' '} + {Math.abs(alteracao.ajusteMinutos) % 60}min + +

+ {/if} +
+ {/if} + + {#if alteracao.motivoDescricao} +

+ Motivo: {alteracao.motivoDescricao} +

+ {/if} + + {#if alteracao.observacoes} +

+ Observações: {alteracao.observacoes} +

+ {/if} + + {#if alteracao.gestor} +

+ Alterado por: {alteracao.gestor.nome} +

+ {/if} +
+
+
+ {/each} +
+
+
+ {:else if historicoAlteracoesQuery?.data && historicoAlteracoesQuery.data.length === 0} +
+
+ +

Nenhuma alteração registrada

+

+ Não há histórico de alterações para {formatarMes(mesSelecionado)}. +

+
+
+ {/if} +
+ diff --git a/apps/web/src/lib/utils/datas.ts b/apps/web/src/lib/utils/datas.ts new file mode 100644 index 0000000..1271331 --- /dev/null +++ b/apps/web/src/lib/utils/datas.ts @@ -0,0 +1,63 @@ +/** + * Utilitários para manipulação de datas + * Resolve problemas de timezone ao trabalhar com datas no formato YYYY-MM-DD + */ + +/** + * Converte uma string de data no formato YYYY-MM-DD para um objeto Date local + * (sem considerar timezone, garantindo que a data seja interpretada como data local) + * + * @param dateString - String no formato YYYY-MM-DD + * @returns Date objeto representando a data local (meia-noite no timezone local) + * + * @example + * parseLocalDate('2024-01-15') // Retorna Date para 15/01/2024 00:00:00 no timezone local + */ +export function parseLocalDate(dateString: string): Date { + if (!dateString || typeof dateString !== 'string') { + throw new Error('dateString deve ser uma string válida'); + } + + // Validar formato YYYY-MM-DD + const dateRegex = /^\d{4}-\d{2}-\d{2}$/; + if (!dateRegex.test(dateString)) { + throw new Error('dateString deve estar no formato YYYY-MM-DD'); + } + + // Extrair ano, mês e dia + const [year, month, day] = dateString.split('-').map(Number); + + // Criar data local (sem considerar timezone) + // O mês no Date é 0-indexed, então subtraímos 1 + const date = new Date(year, month - 1, day, 0, 0, 0, 0); + + // Validar se a data é válida + if (isNaN(date.getTime())) { + throw new Error(`Data inválida: ${dateString}`); + } + + return date; +} + +/** + * Formata uma data para o formato brasileiro (DD/MM/YYYY) + * + * @param date - Date objeto ou string no formato YYYY-MM-DD + * @returns String formatada no formato DD/MM/YYYY + */ +export function formatarDataBR(date: Date | string): string { + let dateObj: Date; + + if (typeof date === 'string') { + dateObj = parseLocalDate(date); + } else { + dateObj = date; + } + + const day = dateObj.getDate().toString().padStart(2, '0'); + const month = (dateObj.getMonth() + 1).toString().padStart(2, '0'); + const year = dateObj.getFullYear(); + + return `${day}/${month}/${year}`; +} + diff --git a/apps/web/src/routes/(dashboard)/gestao-pessoas/gestao-ausencias/+page.svelte b/apps/web/src/routes/(dashboard)/gestao-pessoas/gestao-ausencias/+page.svelte index d242367..bd71c28 100644 --- a/apps/web/src/routes/(dashboard)/gestao-pessoas/gestao-ausencias/+page.svelte +++ b/apps/web/src/routes/(dashboard)/gestao-pessoas/gestao-ausencias/+page.svelte @@ -5,6 +5,7 @@ import { api } from '@sgse-app/backend/convex/_generated/api'; import AprovarAusencias from '$lib/components/AprovarAusencias.svelte'; import type { Id } from '@sgse-app/backend/convex/_generated/dataModel'; + import { parseLocalDate } from '$lib/utils/datas'; const client = useConvexClient(); const currentUser = useQuery(api.auth.getCurrentUser, {}); @@ -53,8 +54,8 @@ } function calcularDias(dataInicio: string, dataFim: string): number { - const inicio = new Date(dataInicio); - const fim = new Date(dataFim); + const inicio = parseLocalDate(dataInicio); + const fim = parseLocalDate(dataFim); const diff = fim.getTime() - inicio.getTime(); return Math.ceil(diff / (1000 * 60 * 60 * 24)) + 1; } @@ -296,8 +297,8 @@ {/if} - {new Date(ausencia.dataInicio).toLocaleDateString('pt-BR')} até{' '} - {new Date(ausencia.dataFim).toLocaleDateString('pt-BR')} + {parseLocalDate(ausencia.dataInicio).toLocaleDateString('pt-BR')} até{' '} + {parseLocalDate(ausencia.dataFim).toLocaleDateString('pt-BR')} {calcularDias(ausencia.dataInicio, ausencia.dataFim)} dias diff --git a/apps/web/src/routes/(dashboard)/perfil/+page.svelte b/apps/web/src/routes/(dashboard)/perfil/+page.svelte index b3409e0..5e41ee1 100644 --- a/apps/web/src/routes/(dashboard)/perfil/+page.svelte +++ b/apps/web/src/routes/(dashboard)/perfil/+page.svelte @@ -11,6 +11,7 @@ import UserAvatar from '$lib/components/chat/UserAvatar.svelte'; import type { Id } from '@sgse-app/backend/convex/_generated/dataModel'; import type { FunctionReturnType } from 'convex/server'; + import { parseLocalDate } from '$lib/utils/datas'; import { X, Calendar, @@ -40,6 +41,7 @@ Smile } from 'lucide-svelte'; import RegistroPonto from '$lib/components/ponto/RegistroPonto.svelte'; + import BancoHorasMensal from '$lib/components/ponto/BancoHorasMensal.svelte'; import TicketCard from '$lib/components/chamados/TicketCard.svelte'; import TicketTimeline from '$lib/components/chamados/TicketTimeline.svelte'; import { chamadosStore } from '$lib/stores/chamados'; @@ -1813,14 +1815,14 @@ {#each ausenciasFiltradas as ausencia (ausencia._id)} - {new Date(ausencia.dataInicio).toLocaleDateString('pt-BR')} até {new Date( + {parseLocalDate(ausencia.dataInicio).toLocaleDateString('pt-BR')} até {parseLocalDate( ausencia.dataFim ).toLocaleDateString('pt-BR')} {Math.ceil( - (new Date(ausencia.dataFim).getTime() - - new Date(ausencia.dataInicio).getTime()) / + (parseLocalDate(ausencia.dataFim).getTime() - + parseLocalDate(ausencia.dataInicio).getTime()) / (1000 * 60 * 60 * 24) ) + 1} dias @@ -2058,16 +2060,16 @@ strokeWidth={2} /> - {new Date(ausencia.dataInicio).toLocaleDateString('pt-BR')} até - {new Date(ausencia.dataFim).toLocaleDateString('pt-BR')} + {parseLocalDate(ausencia.dataInicio).toLocaleDateString('pt-BR')} até + {parseLocalDate(ausencia.dataFim).toLocaleDateString('pt-BR')}
{Math.ceil( - (new Date(ausencia.dataFim).getTime() - - new Date(ausencia.dataInicio).getTime()) / + (parseLocalDate(ausencia.dataFim).getTime() - + parseLocalDate(ausencia.dataInicio).getTime()) / (1000 * 60 * 60 * 24) ) + 1} dias
@@ -2407,29 +2409,43 @@ {#if abaAtiva === 'meu-ponto'} -
-
-
-
-
- -
-
-

- Meu Ponto -

-

- Registre sua entrada, saída e intervalos de trabalho -

+
+ +
+
+
+
+
+ +
+
+

+ Registro de Ponto +

+

+ Registre sua entrada, saída e intervalos de trabalho +

+
+
-
+ + + {#if funcionarioIdDisponivel} +
+
+ +
+
+ {/if}
{/if} diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/ausencias/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/ausencias/+page.svelte index 9e59e03..707dcf4 100644 --- a/apps/web/src/routes/(dashboard)/recursos-humanos/ausencias/+page.svelte +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/ausencias/+page.svelte @@ -6,6 +6,7 @@ import AprovarAusencias from '$lib/components/AprovarAusencias.svelte'; import type { Id } from '@sgse-app/backend/convex/_generated/dataModel'; import { Clock, ArrowLeft, FileText, CheckCircle, XCircle, Info, Eye } from 'lucide-svelte'; + import { parseLocalDate } from '$lib/utils/datas'; const client = useConvexClient(); const currentUser = useQuery(api.auth.getCurrentUser, {}); @@ -54,8 +55,8 @@ } function calcularDias(dataInicio: string, dataFim: string): number { - const inicio = new Date(dataInicio); - const fim = new Date(dataFim); + const inicio = parseLocalDate(dataInicio); + const fim = parseLocalDate(dataFim); const diff = fim.getTime() - inicio.getTime(); return Math.ceil(diff / (1000 * 60 * 60 * 24)) + 1; } @@ -207,8 +208,8 @@ {/if} - {new Date(ausencia.dataInicio).toLocaleDateString('pt-BR')} até{' '} - {new Date(ausencia.dataFim).toLocaleDateString('pt-BR')} + {parseLocalDate(ausencia.dataInicio).toLocaleDateString('pt-BR')} até{' '} + {parseLocalDate(ausencia.dataFim).toLocaleDateString('pt-BR')} {calcularDias(ausencia.dataInicio, ausencia.dataFim)} dias diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/controle-ponto/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/controle-ponto/+page.svelte index 384a6e0..994e595 100644 --- a/apps/web/src/routes/(dashboard)/recursos-humanos/controle-ponto/+page.svelte +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/controle-ponto/+page.svelte @@ -74,5 +74,24 @@

+ + + +
+
+
+ +
+ +
+

Dashboard Banco de Horas

+

+ Visão gerencial do banco de horas, estatísticas e relatórios mensais +

+
+
diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/controle-ponto/banco-horas/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/controle-ponto/banco-horas/+page.svelte new file mode 100644 index 0000000..73d3013 --- /dev/null +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/controle-ponto/banco-horas/+page.svelte @@ -0,0 +1,391 @@ + + +
+ +
+
+
+ +
+
+

Dashboard - Banco de Horas

+

Visão gerencial do banco de horas dos funcionários

+
+
+ +
+ + +
+
+
+ +
+ +
+ + + {formatarMes(mesSelecionado)} + + +
+
+ + +
+ + +
+ + +
+ + +
+
+
+
+ + {#if estatisticasQuery?.isLoading} +
+ +
+ {:else if estatisticas} + +
+ +
+
+
+
+

Total de Funcionários

+

{estatisticas.totalFuncionarios}

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

Saldo Positivo

+

+ {estatisticas.funcionariosPositivos} +

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

Saldo Negativo

+

+ {estatisticas.funcionariosNegativos} +

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

Total Horas Extras

+

+ {Math.floor(estatisticas.totalHorasExtras / 60)}h{' '} + {estatisticas.totalHorasExtras % 60}min +

+
+ +
+
+
+
+ + +
+
+

Detalhamento por Funcionário

+
+ + + + + + + + + + + + + + {#each estatisticas.funcionarios as item} + {#if !apenasNegativos || item.saldoFinalMinutos < 0} + + + + + + + + + + {/if} + {/each} + +
FuncionárioSaldo InicialSaldo do MêsSaldo FinalDias TrabalhadosHoras ExtrasDéficit
+
+
+
+ + {item.funcionario.nome.substring(0, 2).toUpperCase()} + +
+
+
+
{item.funcionario.nome}
+
{item.funcionario.matricula}
+
+
+
+ = 0 ? 'text-success' : 'text-error'} + > + {item.saldoInicialMinutos >= 0 ? '+' : ''} + {Math.floor(Math.abs(item.saldoInicialMinutos) / 60)}h{' '} + {Math.abs(item.saldoInicialMinutos) % 60}min + + + = 0 ? 'text-success' : 'text-error'} + > + {item.saldoMesMinutos >= 0 ? '+' : ''} + {Math.floor(Math.abs(item.saldoMesMinutos) / 60)}h{' '} + {Math.abs(item.saldoMesMinutos) % 60}min + + + = 0 ? 'text-success' : 'text-error'} + > + {item.saldoFinalMinutos >= 0 ? '+' : ''} + {Math.floor(Math.abs(item.saldoFinalMinutos) / 60)}h{' '} + {Math.abs(item.saldoFinalMinutos) % 60}min + + {item.diasTrabalhados} + {Math.floor(item.horasExtras / 60)}h {item.horasExtras % 60}min + + {Math.floor(item.horasDeficit / 60)}h {item.horasDeficit % 60}min +
+
+
+
+ {:else} +
+
+ +

Nenhum dado disponível

+

+ Não há dados de banco de horas para {formatarMes(mesSelecionado)}. +

+
+
+ {/if} +
+ + diff --git a/packages/backend/convex/_generated/api.d.ts b/packages/backend/convex/_generated/api.d.ts index beb3c67..deaea6d 100644 --- a/packages/backend/convex/_generated/api.d.ts +++ b/packages/backend/convex/_generated/api.d.ts @@ -83,6 +83,7 @@ import type * as templatesMensagens from "../templatesMensagens.js"; import type * as times from "../times.js"; import type * as usuarios from "../usuarios.js"; import type * as utils_chatTemplateWrapper from "../utils/chatTemplateWrapper.js"; +import type * as utils_datas from "../utils/datas.js"; import type * as utils_emailTemplateWrapper from "../utils/emailTemplateWrapper.js"; import type * as utils_getClientIP from "../utils/getClientIP.js"; import type * as utils_scanEmailSenders from "../utils/scanEmailSenders.js"; @@ -170,6 +171,7 @@ declare const fullApi: ApiFromModules<{ times: typeof times; usuarios: typeof usuarios; "utils/chatTemplateWrapper": typeof utils_chatTemplateWrapper; + "utils/datas": typeof utils_datas; "utils/emailTemplateWrapper": typeof utils_emailTemplateWrapper; "utils/getClientIP": typeof utils_getClientIP; "utils/scanEmailSenders": typeof utils_scanEmailSenders; diff --git a/packages/backend/convex/ausencias.ts b/packages/backend/convex/ausencias.ts index f01fd69..d52c920 100644 --- a/packages/backend/convex/ausencias.ts +++ b/packages/backend/convex/ausencias.ts @@ -3,6 +3,7 @@ import { mutation, query } from './_generated/server'; import type { QueryCtx, MutationCtx } from './_generated/server'; import { internal, api } from './_generated/api'; import { Id, Doc } from './_generated/dataModel'; +import { parseLocalDate, formatarDataBR } from './utils/datas'; // Query: Listar todas as solicitações (para RH) export const listarTodas = query({ @@ -257,10 +258,10 @@ function verificarSobreposicao( inicio2: string, fim2: string ): boolean { - const d1Inicio = new Date(inicio1); - const d1Fim = new Date(fim1); - const d2Inicio = new Date(inicio2); - const d2Fim = new Date(fim2); + const d1Inicio = parseLocalDate(inicio1); + const d1Fim = parseLocalDate(fim1); + const d2Inicio = parseLocalDate(inicio2); + const d2Fim = parseLocalDate(fim2); return d1Inicio <= d2Fim && d2Inicio <= d1Fim; } @@ -299,12 +300,14 @@ export const criarSolicitacao = mutation({ throw new Error('O motivo deve ter no mínimo 10 caracteres'); } - const dataInicio = new Date(args.dataInicio); - const dataFim = new Date(args.dataFim); + const dataInicio = parseLocalDate(args.dataInicio); + const dataFim = parseLocalDate(args.dataFim); + + // Criar data de hoje em UTC para comparação const hoje = new Date(); - hoje.setHours(0, 0, 0, 0); + const hojeUTC = new Date(Date.UTC(hoje.getUTCFullYear(), hoje.getUTCMonth(), hoje.getUTCDate(), 0, 0, 0, 0)); - if (dataInicio < hoje) { + if (dataInicio < hojeUTC) { throw new Error('A data de início não pode ser no passado'); } @@ -351,7 +354,7 @@ export const criarSolicitacao = mutation({ solicitacaoAusenciaId: solicitacaoId, tipo: 'nova_solicitacao', lida: false, - mensagem: `${funcionario.nome} solicitou uma ausência de ${new Date(args.dataInicio).toLocaleDateString('pt-BR')} até ${new Date(args.dataFim).toLocaleDateString('pt-BR')}` + mensagem: `${funcionario.nome} solicitou uma ausência de ${formatarDataBR(args.dataInicio)} até ${formatarDataBR(args.dataFim)}` }); // Buscar usuário do gestor para enviar email e chat @@ -377,8 +380,8 @@ export const criarSolicitacao = mutation({ variaveis: { gestorNome: gestorUsuario.nome, funcionarioNome: funcionario.nome, - dataInicio: new Date(args.dataInicio).toLocaleDateString('pt-BR'), - dataFim: new Date(args.dataFim).toLocaleDateString('pt-BR'), + dataInicio: formatarDataBR(args.dataInicio), + dataFim: formatarDataBR(args.dataFim), motivo: args.motivo, urlSistema }, @@ -397,7 +400,7 @@ export const criarSolicitacao = mutation({ corpo: `

Olá ${gestorUsuario.nome},

O funcionário ${funcionario.nome} solicitou uma ausência:

    -
  • Período: ${new Date(args.dataInicio).toLocaleDateString('pt-BR')} até ${new Date(args.dataFim).toLocaleDateString('pt-BR')}
  • +
  • Período: ${formatarDataBR(args.dataInicio)} até ${formatarDataBR(args.dataFim)}
  • Motivo: ${args.motivo}

Por favor, acesse o sistema para aprovar ou reprovar esta solicitação.

`, @@ -437,7 +440,7 @@ export const criarSolicitacao = mutation({ conversaId, remetenteId: funcionarioUsuario._id, tipo: 'texto', - conteudo: `Solicitei uma ausência de ${new Date(args.dataInicio).toLocaleDateString('pt-BR')} até ${new Date(args.dataFim).toLocaleDateString('pt-BR')}. Motivo: ${args.motivo}`, + conteudo: `Solicitei uma ausência de ${formatarDataBR(args.dataInicio)} até ${formatarDataBR(args.dataFim)}. Motivo: ${args.motivo}`, enviadaEm: Date.now() }); } @@ -499,7 +502,7 @@ export const aprovar = mutation({ solicitacaoAusenciaId: args.solicitacaoId, tipo: 'aprovado', lida: false, - mensagem: `Sua solicitação de ausência de ${new Date(solicitacao.dataInicio).toLocaleDateString('pt-BR')} até ${new Date(solicitacao.dataFim).toLocaleDateString('pt-BR')} foi aprovada!` + mensagem: `Sua solicitação de ausência de ${formatarDataBR(solicitacao.dataInicio)} até ${formatarDataBR(solicitacao.dataFim)} foi aprovada!` }); const gestorUsuario = await ctx.db.get(args.gestorId); @@ -520,8 +523,8 @@ export const aprovar = mutation({ variaveis: { funcionarioNome: funcionarioUsuario.nome, gestorNome: gestorUsuario.nome, - dataInicio: new Date(solicitacao.dataInicio).toLocaleDateString('pt-BR'), - dataFim: new Date(solicitacao.dataFim).toLocaleDateString('pt-BR'), + dataInicio: formatarDataBR(solicitacao.dataInicio), + dataFim: formatarDataBR(solicitacao.dataFim), motivo: solicitacao.motivo, urlSistema }, @@ -540,7 +543,7 @@ export const aprovar = mutation({ corpo: `

Olá ${funcionarioUsuario.nome},

Sua solicitação de ausência foi aprovada pelo gestor ${gestorUsuario.nome}:

    -
  • Período: ${new Date(solicitacao.dataInicio).toLocaleDateString('pt-BR')} até ${new Date(solicitacao.dataFim).toLocaleDateString('pt-BR')}
  • +
  • Período: ${formatarDataBR(solicitacao.dataInicio)} até ${formatarDataBR(solicitacao.dataFim)}
  • Motivo: ${solicitacao.motivo}
`, enviadoPor: args.gestorId @@ -579,7 +582,7 @@ export const aprovar = mutation({ conversaId, remetenteId: args.gestorId, tipo: 'texto', - conteudo: `Aprovei sua solicitação de ausência de ${new Date(solicitacao.dataInicio).toLocaleDateString('pt-BR')} até ${new Date(solicitacao.dataFim).toLocaleDateString('pt-BR')}.`, + conteudo: `Aprovei sua solicitação de ausência de ${formatarDataBR(solicitacao.dataInicio)} até ${formatarDataBR(solicitacao.dataFim)}.`, enviadaEm: Date.now() }); } @@ -643,7 +646,7 @@ export const reprovar = mutation({ solicitacaoAusenciaId: args.solicitacaoId, tipo: 'reprovado', lida: false, - mensagem: `Sua solicitação de ausência de ${new Date(solicitacao.dataInicio).toLocaleDateString('pt-BR')} até ${new Date(solicitacao.dataFim).toLocaleDateString('pt-BR')} foi reprovada. Motivo: ${args.motivoReprovacao}` + mensagem: `Sua solicitação de ausência de ${formatarDataBR(solicitacao.dataInicio)} até ${formatarDataBR(solicitacao.dataFim)} foi reprovada. Motivo: ${args.motivoReprovacao}` }); const gestorUsuario = await ctx.db.get(args.gestorId); @@ -664,8 +667,8 @@ export const reprovar = mutation({ variaveis: { funcionarioNome: funcionarioUsuario.nome, gestorNome: gestorUsuario.nome, - dataInicio: new Date(solicitacao.dataInicio).toLocaleDateString('pt-BR'), - dataFim: new Date(solicitacao.dataFim).toLocaleDateString('pt-BR'), + dataInicio: formatarDataBR(solicitacao.dataInicio), + dataFim: formatarDataBR(solicitacao.dataFim), motivo: solicitacao.motivo, motivoReprovacao: args.motivoReprovacao, urlSistema @@ -685,7 +688,7 @@ export const reprovar = mutation({ corpo: `

Olá ${funcionarioUsuario.nome},

Sua solicitação de ausência foi reprovada pelo gestor ${gestorUsuario.nome}:

    -
  • Período: ${new Date(solicitacao.dataInicio).toLocaleDateString('pt-BR')} até ${new Date(solicitacao.dataFim).toLocaleDateString('pt-BR')}
  • +
  • Período: ${formatarDataBR(solicitacao.dataInicio)} até ${formatarDataBR(solicitacao.dataFim)}
  • Motivo: ${solicitacao.motivo}
  • Motivo da Reprovação: ${args.motivoReprovacao}
`, @@ -725,7 +728,7 @@ export const reprovar = mutation({ conversaId, remetenteId: args.gestorId, tipo: 'texto', - conteudo: `Reprovei sua solicitação de ausência de ${new Date(solicitacao.dataInicio).toLocaleDateString('pt-BR')} até ${new Date(solicitacao.dataFim).toLocaleDateString('pt-BR')}. Motivo: ${args.motivoReprovacao}`, + conteudo: `Reprovei sua solicitação de ausência de ${formatarDataBR(solicitacao.dataInicio)} até ${formatarDataBR(solicitacao.dataFim)}. Motivo: ${args.motivoReprovacao}`, enviadaEm: Date.now() }); } diff --git a/packages/backend/convex/crons.ts b/packages/backend/convex/crons.ts index ab84993..3ef6845 100644 --- a/packages/backend/convex/crons.ts +++ b/packages/backend/convex/crons.ts @@ -50,4 +50,12 @@ crons.interval( {} ); +// Verificar e enviar notificações de alertas de banco de horas diariamente às 8h +crons.interval( + 'verificar-alertas-banco-horas', + { hours: 24 }, + internal.pontos.enviarNotificacoesAlertasBancoHoras, + {} +); + export default crons; diff --git a/packages/backend/convex/pontos.ts b/packages/backend/convex/pontos.ts index ffaf2f2..befaff2 100644 --- a/packages/backend/convex/pontos.ts +++ b/packages/backend/convex/pontos.ts @@ -1,9 +1,10 @@ import { v } from 'convex/values'; -import { mutation, query } from './_generated/server'; +import { mutation, query, internalMutation } from './_generated/server'; import type { MutationCtx, QueryCtx } from './_generated/server'; import { getCurrentUserFunction } from './auth'; import type { Id } from './_generated/dataModel'; import { validarLocalizacaoGeofencingInternal } from './enderecosMarcacao'; +import { internal } from './_generated/api'; /** * Calcula distância entre duas coordenadas (fórmula de Haversine) @@ -350,6 +351,91 @@ function calcularStatusPonto( return diferenca <= toleranciaMinutos && diferenca >= -toleranciaMinutos; } +/** + * Valida sequência de registros antes de permitir novo registro + * Retorna erro se a sequência não for válida + */ +async function validarSequenciaRegistro( + ctx: QueryCtx | MutationCtx, + funcionarioId: Id<'funcionarios'>, + data: string, + tipoEsperado: 'entrada' | 'saida_almoco' | 'retorno_almoco' | 'saida' +): Promise<{ valido: boolean; motivo?: string }> { + const registrosHoje = await ctx.db + .query('registrosPonto') + .withIndex('by_funcionario_data', (q) => q.eq('funcionarioId', funcionarioId).eq('data', data)) + .order('desc') + .collect(); + + // Se não há registros, só pode ser entrada + if (registrosHoje.length === 0) { + if (tipoEsperado !== 'entrada') { + return { + valido: false, + motivo: 'Primeiro registro do dia deve ser uma entrada' + }; + } + return { valido: true }; + } + + const ultimoRegistro = registrosHoje[0]; + + // Validar sequência lógica + switch (tipoEsperado) { + case 'entrada': + // Só pode registrar entrada se o último foi saída (novo dia) ou não há registros + if (ultimoRegistro.tipo !== 'saida') { + return { + valido: false, + motivo: `Não é possível registrar entrada. Último registro foi: ${ultimoRegistro.tipo}. Esperado: saída do dia anterior.` + }; + } + break; + + case 'saida_almoco': + // Só pode registrar saída almoço se o último foi entrada + if (ultimoRegistro.tipo !== 'entrada') { + return { + valido: false, + motivo: `Não é possível registrar saída para almoço. Último registro foi: ${ultimoRegistro.tipo}. Deve registrar entrada primeiro.` + }; + } + break; + + case 'retorno_almoco': + // Só pode registrar retorno almoço se o último foi saída almoço + if (ultimoRegistro.tipo !== 'saida_almoco') { + return { + valido: false, + motivo: `Não é possível registrar retorno do almoço. Último registro foi: ${ultimoRegistro.tipo}. Deve registrar saída para almoço primeiro.` + }; + } + break; + + case 'saida': + // Só pode registrar saída se o último foi retorno almoço ou entrada (sem intervalo) + if (ultimoRegistro.tipo !== 'retorno_almoco' && ultimoRegistro.tipo !== 'entrada') { + return { + valido: false, + motivo: `Não é possível registrar saída. Último registro foi: ${ultimoRegistro.tipo}. Deve registrar retorno do almoço ou ter apenas entrada.` + }; + } + // Se último foi entrada, verificar se não há saída almoço pendente + if (ultimoRegistro.tipo === 'entrada') { + const temSaidaAlmoco = registrosHoje.some((r) => r.tipo === 'saida_almoco'); + if (temSaidaAlmoco) { + return { + valido: false, + motivo: 'Não é possível registrar saída. Há saída para almoço registrada, mas falta o retorno do almoço.' + }; + } + } + break; + } + + return { valido: true }; +} + /** * Determina o tipo de registro baseado na sequência lógica */ @@ -566,6 +652,18 @@ export const registrarPonto = mutation({ // Determinar tipo de registro const tipo = await determinarTipoRegistro(ctx, usuario.funcionarioId, data); + // Validar sequência de registros + const validacaoSequencia = await validarSequenciaRegistro( + ctx, + usuario.funcionarioId, + data, + tipo + ); + + if (!validacaoSequencia.valido) { + throw new Error(validacaoSequencia.motivo || 'Sequência de registros inválida'); + } + // Calcular horário esperado e tolerância let horarioConfigurado = ''; switch (tipo) { @@ -1212,6 +1310,7 @@ function calcularCargaHorariaDiaria(config: { /** * Calcula horas trabalhadas do dia baseado nos registros + * Trata casos incompletos de forma mais robusta */ function calcularHorasTrabalhadas( registros: Array<{ @@ -1220,6 +1319,10 @@ function calcularHorasTrabalhadas( minuto: number; }> ): number { + if (registros.length === 0) { + return 0; + } + // Ordenar registros por timestamp const registrosOrdenados = [...registros].sort((a, b) => { const minutosA = a.hora * 60 + a.minuto; @@ -1227,35 +1330,78 @@ function calcularHorasTrabalhadas( return minutosA - minutosB; }); - let horasTrabalhadas = 0; - - // Procurar entrada e saída + // Procurar registros principais const entrada = registrosOrdenados.find((r) => r.tipo === 'entrada'); + const saidaAlmoco = registrosOrdenados.find((r) => r.tipo === 'saida_almoco'); + const retornoAlmoco = registrosOrdenados.find((r) => r.tipo === 'retorno_almoco'); const saida = registrosOrdenados.find((r) => r.tipo === 'saida'); + // Caso 1: Tem entrada e saída completas if (entrada && saida) { const minutosEntrada = entrada.hora * 60 + entrada.minuto; const minutosSaida = saida.hora * 60 + saida.minuto; - // Procurar saída e retorno do almoço - const saidaAlmoco = registrosOrdenados.find((r) => r.tipo === 'saida_almoco'); - const retornoAlmoco = registrosOrdenados.find((r) => r.tipo === 'retorno_almoco'); - + // Caso 1.1: Tem intervalo de almoço completo (saída almoço + retorno almoço) if (saidaAlmoco && retornoAlmoco) { - // Tem intervalo de almoço: (saída almoço - entrada) + (saída - retorno almoço) const minutosSaidaAlmoco = saidaAlmoco.hora * 60 + saidaAlmoco.minuto; const minutosRetornoAlmoco = retornoAlmoco.hora * 60 + retornoAlmoco.minuto; - const horasManha = minutosSaidaAlmoco - minutosEntrada; - const horasTarde = minutosSaida - minutosRetornoAlmoco; - horasTrabalhadas = horasManha + horasTarde; - } else { - // Sem intervalo de almoço registrado: saída - entrada - horasTrabalhadas = minutosSaida - minutosEntrada; + // Validar ordem lógica + if ( + minutosSaidaAlmoco > minutosEntrada && + minutosRetornoAlmoco > minutosSaidaAlmoco && + minutosSaida > minutosRetornoAlmoco + ) { + const horasManha = minutosSaidaAlmoco - minutosEntrada; + const horasTarde = minutosSaida - minutosRetornoAlmoco; + return horasManha + horasTarde; + } + } + + // Caso 1.2: Tem apenas saída almoço (sem retorno) - considerar apenas manhã + if (saidaAlmoco && !retornoAlmoco) { + const minutosSaidaAlmoco = saidaAlmoco.hora * 60 + saidaAlmoco.minuto; + if (minutosSaidaAlmoco > minutosEntrada) { + return minutosSaidaAlmoco - minutosEntrada; + } + } + + // Caso 1.3: Tem apenas retorno almoço (sem saída) - considerar apenas tarde + if (!saidaAlmoco && retornoAlmoco) { + const minutosRetornoAlmoco = retornoAlmoco.hora * 60 + retornoAlmoco.minuto; + if (minutosSaida > minutosRetornoAlmoco) { + return minutosSaida - minutosRetornoAlmoco; + } + } + + // Caso 1.4: Sem intervalo de almoço registrado - calcular direto + if (!saidaAlmoco && !retornoAlmoco) { + if (minutosSaida > minutosEntrada) { + return minutosSaida - minutosEntrada; + } } } - return horasTrabalhadas; + // Caso 2: Tem apenas entrada (sem saída) - retornar 0 (dia incompleto) + if (entrada && !saida) { + // Se tiver saída almoço, considerar até a saída almoço + if (saidaAlmoco) { + const minutosEntrada = entrada.hora * 60 + entrada.minuto; + const minutosSaidaAlmoco = saidaAlmoco.hora * 60 + saidaAlmoco.minuto; + if (minutosSaidaAlmoco > minutosEntrada) { + return minutosSaidaAlmoco - minutosEntrada; + } + } + return 0; + } + + // Caso 3: Tem apenas saída (sem entrada) - inconsistência, retornar 0 + if (saida && !entrada) { + return 0; + } + + // Caso 4: Não tem entrada nem saída - retornar 0 + return 0; } /** @@ -1316,6 +1462,10 @@ async function atualizarBancoHoras( calculadoEm: Date.now() }); } + + // Atualizar banco de horas mensal + const mes = data.substring(0, 7); // YYYY-MM + await calcularBancoHorasMensal(ctx, funcionarioId, mes); } /** @@ -1433,6 +1583,372 @@ export const obterBancoHorasFuncionario = query({ } }); +/** + * Calcula e atualiza banco de horas mensal para um funcionário + * Esta função deve ser chamada após atualizações no banco de horas diário + */ +async function calcularBancoHorasMensal( + ctx: MutationCtx, + funcionarioId: Id<'funcionarios'>, + mes: string // YYYY-MM +): Promise { + // Buscar todos os bancoHoras do mês + const dataInicio = `${mes}-01`; + // Calcular último dia do mês: criar data do primeiro dia do mês seguinte e subtrair 1 dia + const [ano, mesNum] = mes.split('-').map(Number); + const ultimoDia = new Date(ano, mesNum, 0).getDate(); // Dia 0 do mês seguinte = último dia do mês atual + const dataFim = `${mes}-${String(ultimoDia).padStart(2, '0')}`; + + const bancosHorasDoMes = await ctx.db + .query('bancoHoras') + .withIndex('by_funcionario', (q) => q.eq('funcionarioId', funcionarioId)) + .filter((q) => { + const data = q.field('data'); + return q.and(q.gte(data, dataInicio), q.lte(data, dataFim)); + }) + .collect(); + + // Calcular saldo do mês anterior para obter saldo inicial + const mesAnterior = new Date(`${mes}-01`); + mesAnterior.setMonth(mesAnterior.getMonth() - 1); + const mesAnteriorStr = `${mesAnterior.getFullYear()}-${String(mesAnterior.getMonth() + 1).padStart(2, '0')}`; + + const bancoMensalAnterior = await ctx.db + .query('bancoHorasMensal') + .withIndex('by_funcionario_mes', (q) => + q.eq('funcionarioId', funcionarioId).eq('mes', mesAnteriorStr) + ) + .first(); + + const saldoInicialMinutos = bancoMensalAnterior?.saldoFinalMinutos || 0; + + // Calcular estatísticas do mês + const diasTrabalhados = bancosHorasDoMes.length; + const saldoMesMinutos = bancosHorasDoMes.reduce((acc, bh) => acc + bh.saldoMinutos, 0); + const saldoFinalMinutos = saldoInicialMinutos + saldoMesMinutos; + + // Separar horas extras e déficit + const horasExtras = bancosHorasDoMes + .filter((bh) => bh.saldoMinutos > 0) + .reduce((acc, bh) => acc + bh.saldoMinutos, 0); + const horasDeficit = Math.abs( + bancosHorasDoMes + .filter((bh) => bh.saldoMinutos < 0) + .reduce((acc, bh) => acc + bh.saldoMinutos, 0) + ); + + const agora = Date.now(); + + // Buscar ou criar registro mensal + const bancoMensalExistente = await ctx.db + .query('bancoHorasMensal') + .withIndex('by_funcionario_mes', (q) => + q.eq('funcionarioId', funcionarioId).eq('mes', mes) + ) + .first(); + + if (bancoMensalExistente) { + // Atualizar existente + await ctx.db.patch(bancoMensalExistente._id, { + saldoInicialMinutos, + saldoFinalMinutos, + saldoMesMinutos, + diasTrabalhados, + horasExtras, + horasDeficit, + atualizadoEm: agora + }); + } else { + // Criar novo + await ctx.db.insert('bancoHorasMensal', { + funcionarioId, + mes, + saldoInicialMinutos, + saldoFinalMinutos, + saldoMesMinutos, + diasTrabalhados, + horasExtras, + horasDeficit, + calculadoEm: agora, + atualizadoEm: agora + }); + } +} + +/** + * Obtém banco de horas mensal de um funcionário + */ +export const obterBancoHorasMensal = query({ + args: { + funcionarioId: v.id('funcionarios'), + mes: v.string() // YYYY-MM + }, + handler: async (ctx, args) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) { + throw new Error('Usuário não autenticado'); + } + + // Verificar se é o próprio funcionário ou tem permissão + if (usuario.funcionarioId !== args.funcionarioId) { + // TODO: Verificar permissão de RH + } + + const bancoMensal = await ctx.db + .query('bancoHorasMensal') + .withIndex('by_funcionario_mes', (q) => + q.eq('funcionarioId', args.funcionarioId).eq('mes', args.mes) + ) + .first(); + + if (!bancoMensal) { + // Retornar valores zerados se não existe + return { + mes: args.mes, + saldoInicialMinutos: 0, + saldoFinalMinutos: 0, + saldoMesMinutos: 0, + diasTrabalhados: 0, + horasExtras: 0, + horasDeficit: 0, + saldoFormatado: { + inicial: { horas: 0, minutos: 0, positivo: true }, + final: { horas: 0, minutos: 0, positivo: true }, + mes: { horas: 0, minutos: 0, positivo: true } + } + }; + } + + // Formatar valores + const formatarSaldo = (minutos: number) => { + const horas = Math.floor(Math.abs(minutos) / 60); + const mins = Math.abs(minutos) % 60; + return { horas, minutos: mins, positivo: minutos >= 0 }; + }; + + return { + ...bancoMensal, + saldoFormatado: { + inicial: formatarSaldo(bancoMensal.saldoInicialMinutos), + final: formatarSaldo(bancoMensal.saldoFinalMinutos), + mes: formatarSaldo(bancoMensal.saldoMesMinutos) + } + }; + } +}); + +/** + * Lista histórico mensal de banco de horas de um funcionário + */ +export const listarHistoricoMensal = query({ + args: { + funcionarioId: v.id('funcionarios'), + mesInicio: v.optional(v.string()), // YYYY-MM + mesFim: v.optional(v.string()) // YYYY-MM + }, + handler: async (ctx, args) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) { + throw new Error('Usuário não autenticado'); + } + + // Verificar se é o próprio funcionário ou tem permissão + if (usuario.funcionarioId !== args.funcionarioId) { + // TODO: Verificar permissão de RH + } + + let query = ctx.db + .query('bancoHorasMensal') + .withIndex('by_funcionario', (q) => q.eq('funcionarioId', args.funcionarioId)); + + // Filtrar por período se fornecido + if (args.mesInicio || args.mesFim) { + query = query.filter((q) => { + const mes = q.field('mes'); + if (args.mesInicio && args.mesFim) { + return q.and(q.gte(mes, args.mesInicio), q.lte(mes, args.mesFim)); + } else if (args.mesInicio) { + return q.gte(mes, args.mesInicio); + } else if (args.mesFim) { + return q.lte(mes, args.mesFim); + } + return true; + }); + } + + const bancosMensais = await query.order('desc').collect(); + + // Formatar valores + const formatarSaldo = (minutos: number) => { + const horas = Math.floor(Math.abs(minutos) / 60); + const mins = Math.abs(minutos) % 60; + return { horas, minutos: mins, positivo: minutos >= 0 }; + }; + + return bancosMensais.map((bm) => ({ + ...bm, + saldoFormatado: { + inicial: formatarSaldo(bm.saldoInicialMinutos), + final: formatarSaldo(bm.saldoFinalMinutos), + mes: formatarSaldo(bm.saldoMesMinutos), + extras: formatarSaldo(bm.horasExtras), + deficit: formatarSaldo(-bm.horasDeficit) + } + })); + } +}); + +/** + * Envia notificações push para alertas de banco de horas + * Esta função deve ser chamada periodicamente (via cron ou scheduler) + */ +export const enviarNotificacoesAlertasBancoHoras = internalMutation({ + args: {}, + handler: async (ctx) => { + // Buscar todos os funcionários ativos (sem data de desligamento) + const todosFuncionarios = await ctx.db.query('funcionarios').collect(); + const funcionarios = todosFuncionarios.filter((f) => !f.desligamentoData); + + let notificacoesEnviadas = 0; + + for (const funcionario of funcionarios) { + // Buscar usuário associado + const usuario = await ctx.db + .query('usuarios') + .withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', funcionario._id)) + .first(); + + if (!usuario) continue; + + // Verificar alertas + const hoje = new Date(); + const mesAtual = `${hoje.getFullYear()}-${String(hoje.getMonth() + 1).padStart(2, '0')}`; + + const bancoMensal = await ctx.db + .query('bancoHorasMensal') + .withIndex('by_funcionario_mes', (q) => + q.eq('funcionarioId', funcionario._id).eq('mes', mesAtual) + ) + .first(); + + if (bancoMensal && bancoMensal.saldoFinalMinutos < 0) { + const horasNegativas = Math.abs(bancoMensal.saldoFinalMinutos) / 60; + const minutosNegativos = Math.abs(bancoMensal.saldoFinalMinutos) % 60; + + // Enviar notificação apenas se saldo negativo for significativo (> 1 hora) + if (horasNegativas >= 1) { + const titulo = horasNegativas > 8 + ? '⚠️ Alerta Crítico: Saldo Negativo de Banco de Horas' + : '⚠️ Atenção: Saldo Negativo de Banco de Horas'; + const corpo = `Seu saldo acumulado está negativo em ${Math.floor(horasNegativas)}h ${minutosNegativos}min. Considere compensar horas ou entrar em contato com seu gestor.`; + + // Enviar push notification + await ctx.scheduler.runAfter(0, internal.pushNotifications.enviarPushNotification, { + usuarioId: usuario._id, + titulo, + corpo, + data: { + tipo: 'banco_horas_alerta' + } + }); + + notificacoesEnviadas++; + } + } + } + + return { notificacoesEnviadas }; + } +}); + +/** + * Verifica alertas de banco de horas (saldo negativo, etc) + */ +export const verificarAlertasBancoHoras = query({ + args: { + funcionarioId: v.id('funcionarios') + }, + handler: async (ctx, args) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) { + throw new Error('Usuário não autenticado'); + } + + // Verificar se é o próprio funcionário ou tem permissão + if (usuario.funcionarioId !== args.funcionarioId) { + // TODO: Verificar permissão de RH + } + + // Buscar banco de horas mensal mais recente + const hoje = new Date(); + const mesAtual = `${hoje.getFullYear()}-${String(hoje.getMonth() + 1).padStart(2, '0')}`; + + const bancoMensal = await ctx.db + .query('bancoHorasMensal') + .withIndex('by_funcionario_mes', (q) => + q.eq('funcionarioId', args.funcionarioId).eq('mes', mesAtual) + ) + .first(); + + const alertas: Array<{ + tipo: 'saldo_negativo' | 'saldo_negativo_critico' | 'dias_sem_registro'; + severidade: 'warning' | 'error'; + mensagem: string; + valor?: number; + }> = []; + + if (bancoMensal) { + // Alerta 1: Saldo negativo acumulado + if (bancoMensal.saldoFinalMinutos < 0) { + const horasNegativas = Math.abs(bancoMensal.saldoFinalMinutos) / 60; + alertas.push({ + tipo: horasNegativas > 8 ? 'saldo_negativo_critico' : 'saldo_negativo', + severidade: horasNegativas > 8 ? 'error' : 'warning', + mensagem: `Saldo negativo acumulado de ${Math.floor(Math.abs(bancoMensal.saldoFinalMinutos) / 60)}h ${Math.abs(bancoMensal.saldoFinalMinutos) % 60}min`, + valor: bancoMensal.saldoFinalMinutos + }); + } + } + + // Verificar dias sem registro nos últimos 7 dias + const ultimos7Dias: string[] = []; + for (let i = 0; i < 7; i++) { + const data = new Date(); + data.setDate(data.getDate() - i); + ultimos7Dias.push(data.toISOString().split('T')[0]!); + } + + const registrosRecentes = await ctx.db + .query('bancoHoras') + .withIndex('by_funcionario', (q) => q.eq('funcionarioId', args.funcionarioId)) + .filter((q) => { + const data = q.field('data'); + return q.or( + ...ultimos7Dias.map((dia) => q.eq(data, dia)) + ); + }) + .collect(); + + const diasComRegistro = new Set(registrosRecentes.map((r) => r.data)); + const diasSemRegistro = ultimos7Dias.filter((dia) => !diasComRegistro.has(dia)); + + if (diasSemRegistro.length >= 3) { + alertas.push({ + tipo: 'dias_sem_registro', + severidade: 'warning', + mensagem: `${diasSemRegistro.length} dias sem registro de ponto nos últimos 7 dias`, + valor: diasSemRegistro.length + }); + } + + return { + alertas, + temAlertas: alertas.length > 0, + bancoMensalAtual: bancoMensal + }; + } +}); + /** * Helper: Verificar se usuário é gestor do funcionário */ @@ -1518,7 +2034,7 @@ export const editarRegistroPonto = mutation({ homologacaoId }); - // Recalcular banco de horas do dia + // Recalcular banco de horas do dia (isso já atualiza o mensal automaticamente) const config = await ctx.db .query('configuracaoPonto') .withIndex('by_ativo', (q) => q.eq('ativo', true)) @@ -1606,6 +2122,10 @@ export const ajustarBancoHoras = mutation({ }); } + // Recalcular banco de horas mensal após ajuste + const mes = hoje.substring(0, 7); // YYYY-MM + await calcularBancoHorasMensal(ctx, args.funcionarioId, mes); + // Criar registro de homologação const homologacaoId = await ctx.db.insert('homologacoesPonto', { funcionarioId: args.funcionarioId, @@ -1978,6 +2498,163 @@ export const listarDispensas = query({ } }); +/** + * Obtém estatísticas gerenciais do banco de horas para RH + */ +export const obterEstatisticasBancoHorasGerencial = query({ + args: { + mes: v.string(), // YYYY-MM + funcionarioId: v.optional(v.id('funcionarios')) + }, + handler: async (ctx, args) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) { + throw new Error('Usuário não autenticado'); + } + + // TODO: Verificar permissão de RH/TI + + // Buscar todos os bancos de horas do mês + let bancosMensais = await ctx.db + .query('bancoHorasMensal') + .withIndex('by_mes', (q) => q.eq('mes', args.mes)) + .collect(); + + // Filtrar por funcionário se fornecido + if (args.funcionarioId) { + bancosMensais = bancosMensais.filter((b) => b.funcionarioId === args.funcionarioId); + } + + // Buscar informações dos funcionários + const funcionariosComDetalhes = await Promise.all( + bancosMensais.map(async (banco) => { + const funcionario = await ctx.db.get(banco.funcionarioId); + return { + ...banco, + funcionario: funcionario + ? { + nome: funcionario.nome, + matricula: funcionario.matricula + } + : null + }; + }) + ); + + // Calcular estatísticas gerais + const totalFuncionarios = funcionariosComDetalhes.length; + const funcionariosPositivos = funcionariosComDetalhes.filter( + (f) => f.saldoFinalMinutos >= 0 + ).length; + const funcionariosNegativos = totalFuncionarios - funcionariosPositivos; + const totalHorasExtras = funcionariosComDetalhes.reduce( + (acc, f) => acc + f.horasExtras, + 0 + ); + const totalDeficit = funcionariosComDetalhes.reduce((acc, f) => acc + f.horasDeficit, 0); + + return { + mes: args.mes, + totalFuncionarios, + funcionariosPositivos, + funcionariosNegativos, + totalHorasExtras, + totalDeficit, + funcionarios: funcionariosComDetalhes + }; + } +}); + +/** + * Lista histórico de alterações no banco de horas (homologações e ajustes) + */ +export const listarHistoricoAlteracoesBancoHoras = query({ + args: { + funcionarioId: v.id('funcionarios'), + mes: v.optional(v.string()) // YYYY-MM - se fornecido, filtra por mês + }, + handler: async (ctx, args) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) { + throw new Error('Usuário não autenticado'); + } + + // Verificar se é o próprio funcionário ou tem permissão + if (usuario.funcionarioId !== args.funcionarioId) { + // TODO: Verificar permissão de RH + } + + // Buscar homologações do funcionário + let homologacoes = await ctx.db + .query('homologacoesPonto') + .withIndex('by_funcionario', (q) => q.eq('funcionarioId', args.funcionarioId)) + .order('desc') + .collect(); + + // Filtrar por mês se fornecido + if (args.mes) { + const mesHomologacao = args.mes; + homologacoes = homologacoes.filter((h) => { + const dataHomologacao = new Date(h.criadoEm); + const mesHomologacaoStr = `${dataHomologacao.getFullYear()}-${String(dataHomologacao.getMonth() + 1).padStart(2, '0')}`; + return mesHomologacaoStr === mesHomologacao; + }); + } + + // Buscar informações adicionais + const historicoComDetalhes = await Promise.all( + homologacoes.map(async (h) => { + const gestor = await ctx.db.get(h.gestorId); + const registro = h.registroId ? await ctx.db.get(h.registroId) : null; + + // Determinar tipo de alteração + let tipoAlteracao: 'edicao_registro' | 'ajuste_banco' | 'outro' = 'outro'; + if (h.registroId && h.horaAnterior !== undefined) { + tipoAlteracao = 'edicao_registro'; + } else if (h.tipoAjuste) { + tipoAlteracao = 'ajuste_banco'; + } + + // Calcular diferença em minutos (se for edição de registro) + let diferencaMinutos: number | undefined = undefined; + if (h.horaAnterior !== undefined && h.horaNova !== undefined) { + const minutosAnterior = h.horaAnterior * 60 + (h.minutoAnterior || 0); + const minutosNovo = h.horaNova * 60 + (h.minutoNova || 0); + diferencaMinutos = minutosNovo - minutosAnterior; + } + + return { + ...h, + tipoAlteracao, + diferencaMinutos, + gestor: gestor + ? { + nome: gestor.nome + } + : null, + registro: registro + ? { + data: registro.data, + tipo: registro.tipo, + horaAnterior: `${String(h.horaAnterior || 0).padStart(2, '0')}:${String(h.minutoAnterior || 0).padStart(2, '0')}`, + horaNova: `${String(h.horaNova || 0).padStart(2, '0')}:${String(h.minutoNova || 0).padStart(2, '0')}` + } + : null, + dataFormatada: new Date(h.criadoEm).toLocaleDateString('pt-BR', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }) + }; + }) + ); + + return historicoComDetalhes; + } +}); + /** * Verifica se funcionário está dispensado de registrar ponto em uma data/hora específica */ diff --git a/packages/backend/convex/tables/ponto.ts b/packages/backend/convex/tables/ponto.ts index 4233db6..3fb2992 100644 --- a/packages/backend/convex/tables/ponto.ts +++ b/packages/backend/convex/tables/ponto.ts @@ -210,6 +210,23 @@ export const pontoTables = { .index('by_funcionario', ['funcionarioId']) .index('by_data', ['data']), + // Banco de Horas Mensal - Agregação mensal do banco de horas + bancoHorasMensal: defineTable({ + funcionarioId: v.id('funcionarios'), + mes: v.string(), // YYYY-MM (ex: "2024-01") + saldoInicialMinutos: v.number(), // Saldo acumulado do mês anterior (pode ser negativo) + saldoFinalMinutos: v.number(), // Saldo acumulado ao final do mês + saldoMesMinutos: v.number(), // Saldo apenas do mês atual (sem acumular) + diasTrabalhados: v.number(), // Quantidade de dias com registros no mês + horasExtras: v.number(), // Total de minutos positivos do mês + horasDeficit: v.number(), // Total de minutos negativos do mês (valor absoluto) + calculadoEm: v.number(), + atualizadoEm: v.number() + }) + .index('by_funcionario_mes', ['funcionarioId', 'mes']) + .index('by_funcionario', ['funcionarioId']) + .index('by_mes', ['mes']), + // Homologações de Ponto - Edições e ajustes realizados pelo gestor homologacoesPonto: defineTable({ registroId: v.optional(v.id('registrosPonto')), // ID do registro editado (se for edição) diff --git a/packages/backend/convex/utils/datas.ts b/packages/backend/convex/utils/datas.ts new file mode 100644 index 0000000..62515ea --- /dev/null +++ b/packages/backend/convex/utils/datas.ts @@ -0,0 +1,65 @@ +/** + * Utilitários para manipulação de datas no backend + * Resolve problemas de timezone ao trabalhar com datas no formato YYYY-MM-DD + */ + +/** + * Converte uma string de data no formato YYYY-MM-DD para um objeto Date local + * No ambiente Convex, as datas são tratadas como UTC, então precisamos garantir + * que a data seja interpretada corretamente. + * + * @param dateString - String no formato YYYY-MM-DD + * @returns Date objeto representando a data + * + * @example + * parseLocalDate('2024-01-15') // Retorna Date para 15/01/2024 + */ +export function parseLocalDate(dateString: string): Date { + if (!dateString || typeof dateString !== 'string') { + throw new Error('dateString deve ser uma string válida'); + } + + // Validar formato YYYY-MM-DD + const dateRegex = /^\d{4}-\d{2}-\d{2}$/; + if (!dateRegex.test(dateString)) { + throw new Error('dateString deve estar no formato YYYY-MM-DD'); + } + + // Extrair ano, mês e dia + const [year, month, day] = dateString.split('-').map(Number); + + // No Convex, criar a data usando UTC para evitar problemas de timezone + // Usamos UTC para garantir consistência, mas mantemos a data correta + const date = new Date(Date.UTC(year, month - 1, day, 0, 0, 0, 0)); + + // Validar se a data é válida + if (isNaN(date.getTime())) { + throw new Error(`Data inválida: ${dateString}`); + } + + return date; +} + +/** + * Formata uma data para o formato brasileiro (DD/MM/YYYY) + * + * @param date - Date objeto ou string no formato YYYY-MM-DD + * @returns String formatada no formato DD/MM/YYYY + */ +export function formatarDataBR(date: Date | string): string { + let dateObj: Date; + + if (typeof date === 'string') { + dateObj = parseLocalDate(date); + } else { + dateObj = date; + } + + // Usar UTC para garantir consistência + const day = dateObj.getUTCDate().toString().padStart(2, '0'); + const month = (dateObj.getUTCMonth() + 1).toString().padStart(2, '0'); + const year = dateObj.getUTCFullYear(); + + return `${day}/${month}/${year}`; +} +