feat: enhance Banco de Horas management with new reporting features, including adjustments and inconsistencies tracking, advanced filters, and Excel export functionality

This commit is contained in:
2025-12-06 09:32:55 -03:00
parent 72450d1f28
commit aec3201410
14 changed files with 4730 additions and 22 deletions

View File

@@ -4,6 +4,7 @@ import { Id } from './_generated/dataModel';
import type { QueryCtx, MutationCtx } from './_generated/server';
import { registrarAtividade } from './logsAtividades';
import { getCurrentUserFunction } from './auth';
import { internal } from './_generated/api';
// ========== HELPERS ==========
@@ -26,6 +27,38 @@ function calcularDias(dataInicio: string, dataFim: string): number {
return diffDays;
}
/**
* Helper para recalcular banco de horas em um período
*/
async function recalcularBancoHorasPeriodo(
ctx: MutationCtx,
funcionarioId: Id<'funcionarios'>,
dataInicio: string,
dataFim: string
): Promise<void> {
// Gerar todas as datas do período
const dataInicioObj = new Date(dataInicio);
const dataFimObj = new Date(dataFim);
const datas: string[] = [];
const dataAtual = new Date(dataInicioObj);
while (dataAtual <= dataFimObj) {
const ano = dataAtual.getFullYear();
const mes = String(dataAtual.getMonth() + 1).padStart(2, '0');
const dia = String(dataAtual.getDate()).padStart(2, '0');
datas.push(`${ano}-${mes}-${dia}`);
dataAtual.setDate(dataAtual.getDate() + 1);
}
// Recalcular para cada data usando a mutation interna (agendar para execução assíncrona)
for (let i = 0; i < datas.length; i++) {
await ctx.scheduler.runAfter(i * 100, internal.pontos.recalcularBancoHorasData, {
funcionarioId,
data: datas[i]!
});
}
}
// ========== QUERIES ==========
/**
@@ -780,6 +813,9 @@ export const criarAtestadoMedico = mutation({
atestadoId
);
// Recalcular banco de horas para todas as datas do período do atestado
await recalcularBancoHorasPeriodo(ctx, args.funcionarioId, args.dataInicio, args.dataFim);
return atestadoId;
}
});
@@ -825,6 +861,9 @@ export const criarDeclaracaoComparecimento = mutation({
atestadoId
);
// Recalcular banco de horas para todas as datas do período da declaração
await recalcularBancoHorasPeriodo(ctx, args.funcionarioId, args.dataInicio, args.dataFim);
return atestadoId;
}
});
@@ -878,6 +917,9 @@ export const criarLicencaMaternidade = mutation({
licencaId
);
// Recalcular banco de horas para todas as datas do período da licença
await recalcularBancoHorasPeriodo(ctx, args.funcionarioId, args.dataInicio, args.dataFim);
return licencaId;
}
});
@@ -924,6 +966,9 @@ export const criarLicencaPaternidade = mutation({
licencaId
);
// Recalcular banco de horas para todas as datas do período da licença
await recalcularBancoHorasPeriodo(ctx, args.funcionarioId, args.dataInicio, args.dataFim);
return licencaId;
}
});

View File

@@ -2,6 +2,7 @@ import { v } from 'convex/values';
import { mutation, query } from './_generated/server';
import type { QueryCtx, MutationCtx } from './_generated/server';
import { api } from './_generated/api';
import { internal } from './_generated/api';
import { Id, Doc } from './_generated/dataModel';
import { parseLocalDate, formatarDataBR } from './utils/datas';
import { getCurrentUserFunction } from './auth';
@@ -267,6 +268,36 @@ export const contarPendentesGestor = query({
}
});
// Helper: Recalcular banco de horas em um período
async function recalcularBancoHorasPeriodo(
ctx: MutationCtx,
funcionarioId: Id<'funcionarios'>,
dataInicio: string,
dataFim: string
): Promise<void> {
// Gerar todas as datas do período
const dataInicioObj = new Date(dataInicio);
const dataFimObj = new Date(dataFim);
const datas: string[] = [];
const dataAtual = new Date(dataInicioObj);
while (dataAtual <= dataFimObj) {
const ano = dataAtual.getFullYear();
const mes = String(dataAtual.getMonth() + 1).padStart(2, '0');
const dia = String(dataAtual.getDate()).padStart(2, '0');
datas.push(`${ano}-${mes}-${dia}`);
dataAtual.setDate(dataAtual.getDate() + 1);
}
// Recalcular para cada data usando a mutation interna (agendar para execução assíncrona)
for (let i = 0; i < datas.length; i++) {
await ctx.scheduler.runAfter(i * 100, internal.pontos.recalcularBancoHorasData, {
funcionarioId,
data: datas[i]!
});
}
}
// Helper: Verificar se há sobreposição de datas
function verificarSobreposicao(
inicio1: string,
@@ -641,6 +672,9 @@ export const aprovar = mutation({
}
}
// Recalcular banco de horas para todas as datas do período da ausência aprovada
await recalcularBancoHorasPeriodo(ctx, solicitacao.funcionarioId, solicitacao.dataInicio, solicitacao.dataFim);
return null;
}
});

File diff suppressed because it is too large Load Diff

View File

@@ -204,11 +204,26 @@ export const pontoTables = {
horasTrabalhadas: v.number(), // Horas realmente trabalhadas (em minutos)
saldoMinutos: v.number(), // Saldo do dia (positivo = horas extras, negativo = déficit)
registrosPontoIds: v.array(v.id('registrosPonto')), // IDs dos registros do dia
// Novos campos para sistema avançado
ajustesIds: v.optional(v.array(v.id('ajustesBancoHoras'))), // IDs dos ajustes aplicados no dia
motivoAbono: v.optional(v.string()), // Motivo do abono (atestado, licença, ausência, etc.)
tipoDia: v.optional(
v.union(
v.literal('normal'),
v.literal('atestado'),
v.literal('licenca'),
v.literal('ausencia'),
v.literal('abonado'),
v.literal('descontado')
)
), // Tipo do dia
inconsistenciasIds: v.optional(v.array(v.id('inconsistenciasBancoHoras'))), // IDs de inconsistências detectadas
calculadoEm: v.number()
})
.index('by_funcionario_data', ['funcionarioId', 'data'])
.index('by_funcionario', ['funcionarioId'])
.index('by_data', ['data']),
.index('by_data', ['data'])
.index('by_tipo_dia', ['tipoDia']),
// Banco de Horas Mensal - Agregação mensal do banco de horas
bancoHorasMensal: defineTable({
@@ -220,6 +235,11 @@ export const pontoTables = {
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)
// Novos campos para sistema avançado
totalAjustes: v.optional(v.number()), // Total de ajustes aplicados no mês (em minutos)
totalAbonos: v.optional(v.number()), // Total de abonos no mês (em minutos)
totalDescontos: v.optional(v.number()), // Total de descontos no mês (em minutos)
inconsistenciasResolvidas: v.optional(v.number()), // Quantidade de inconsistências resolvidas
calculadoEm: v.number(),
atualizadoEm: v.number()
})
@@ -279,5 +299,119 @@ export const pontoTables = {
.index('by_gestor', ['gestorId'])
.index('by_ativo', ['ativo'])
.index('by_data_inicio', ['dataInicio'])
.index('by_data_fim', ['dataFim'])
.index('by_data_fim', ['dataFim']),
// Configuração de Banco de Horas - Configurações gerais do sistema
configuracaoBancoHoras: defineTable({
// Limites de saldo
limiteSaldoPositivoMinutos: v.optional(v.number()), // Limite máximo de saldo positivo (em minutos)
limiteSaldoNegativoMinutos: v.optional(v.number()), // Limite máximo de saldo negativo (em minutos)
// Regras de cálculo
considerarAjustesAutomaticos: v.optional(v.boolean()), // Se deve considerar ajustes automáticos (atestados, licenças, ausências)
// Periodicidade de verificação
periodicidadeVerificacao: v.optional(
v.union(v.literal('diario'), v.literal('semanal'), v.literal('mensal'))
),
// Metadados
atualizadoPor: v.id('usuarios'),
atualizadoEm: v.number()
}).index('by_ativo', ['atualizadoEm']),
// Alertas de Banco de Horas - Configuração de alertas por tipo
alertasBancoHoras: defineTable({
tipoAlerta: v.union(
v.literal('saldo_negativo'),
v.literal('saldo_negativo_critico'),
v.literal('inconsistencia_detectada'),
v.literal('dias_sem_registro'),
v.literal('limite_saldo_excedido')
),
// Periodicidade
periodicidade: v.union(v.literal('diario'), v.literal('semanal'), v.literal('mensal')),
// Canais de envio
enviarEmail: v.boolean(),
enviarChat: v.boolean(),
// Destinatários específicos (opcional - se vazio, envia para gestor padrão)
destinatariosEmail: v.optional(v.array(v.id('usuarios'))), // IDs de usuários que receberão email
destinatariosChat: v.optional(v.array(v.id('usuarios'))), // IDs de usuários que receberão chat
// Thresholds e limites
threshold: v.optional(v.number()), // Valor limite para disparar alerta
limiteMinutos: v.optional(v.number()), // Limite em minutos (para saldo negativo)
// Status
ativo: v.boolean(),
// Metadados
criadoPor: v.id('usuarios'),
criadoEm: v.number(),
atualizadoPor: v.optional(v.id('usuarios')),
atualizadoEm: v.optional(v.number())
})
.index('by_tipo', ['tipoAlerta'])
.index('by_ativo', ['ativo'])
.index('by_tipo_ativo', ['tipoAlerta', 'ativo']),
// Ajustes de Banco de Horas - Registro de ajustes manuais e automáticos
ajustesBancoHoras: defineTable({
funcionarioId: v.id('funcionarios'),
tipo: v.union(v.literal('abonar'), v.literal('descontar'), v.literal('compensar')),
// Motivo vinculado
motivoTipo: v.optional(
v.union(
v.literal('atestado'),
v.literal('licenca'),
v.literal('ausencia'),
v.literal('manual')
)
),
motivoId: v.optional(v.string()), // ID do atestado, licença, ausência ou null para manual
motivoDescricao: v.optional(v.string()), // Descrição do motivo
// Valor do ajuste
valorMinutos: v.number(), // Valor em minutos (positivo para abonar, negativo para descontar)
// Data de aplicação
dataAplicacao: v.string(), // YYYY-MM-DD
// Gestor responsável (null se automático)
gestorId: v.optional(v.id('usuarios')),
// Observações
observacoes: v.optional(v.string()),
// Status
aplicado: v.boolean(), // Se já foi aplicado ao banco de horas
// Metadados
criadoEm: v.number(),
aplicadoEm: v.optional(v.number())
})
.index('by_funcionario', ['funcionarioId'])
.index('by_data_aplicacao', ['dataAplicacao'])
.index('by_funcionario_data', ['funcionarioId', 'dataAplicacao'])
.index('by_tipo', ['tipo'])
.index('by_aplicado', ['aplicado'])
.index('by_gestor', ['gestorId']),
// Inconsistências de Banco de Horas - Registro de inconsistências detectadas
inconsistenciasBancoHoras: defineTable({
funcionarioId: v.id('funcionarios'),
tipo: v.union(
v.literal('ponto_com_atestado'),
v.literal('ponto_com_licenca'),
v.literal('ponto_com_ausencia'),
v.literal('registro_duplicado'),
v.literal('sequencia_invalida'),
v.literal('saldo_inconsistente')
),
descricao: v.string(), // Descrição detalhada da inconsistência
dataDetectada: v.string(), // YYYY-MM-DD
dataInconsistencia: v.string(), // YYYY-MM-DD (data do dia com inconsistência)
// Status
status: v.union(v.literal('pendente'), v.literal('resolvida'), v.literal('ignorada')),
// Resolução
resolucao: v.optional(v.string()), // Descrição da resolução
resolvidoPor: v.optional(v.id('usuarios')), // Usuário que resolveu
resolvidoEm: v.optional(v.number()), // Timestamp da resolução
// Metadados
criadoEm: v.number()
})
.index('by_funcionario', ['funcionarioId'])
.index('by_status', ['status'])
.index('by_funcionario_status', ['funcionarioId', 'status'])
.index('by_data_detectada', ['dataDetectada'])
.index('by_tipo', ['tipo'])
.index('by_data_inconsistencia', ['dataInconsistencia'])
};