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 @@
+
+
+
+
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(
+ {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
+
+
+
+
+ Exportar Relatório
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Funcionário
+
+
+ Todos os funcionários
+ {#each funcionarios as func}
+ {func.nome} - {func.matricula}
+ {/each}
+
+
+
+
+
+
+ Filtros
+
+
+ Apenas saldos negativos
+
+
+
+
+
+
+
+ {#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
+
+
+
+
+ Funcionário
+ Saldo Inicial
+ Saldo do Mês
+ Saldo Final
+ Dias Trabalhados
+ Horas Extras
+ Déficit
+
+
+
+ {#each estatisticas.funcionarios as item}
+ {#if !apenasNegativos || item.saldoFinalMinutos < 0}
+
+
+
+
+
+
+ {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
+
+
+ {/if}
+ {/each}
+
+
+
+
+
+ {: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}`;
+}
+