feat: implement comparative balance calculation for entry/exit pairs in registro-pontos page

- Added a new function `calcularSaldoComparativoPorPar` to compute comparative balances for entry and exit pairs, enhancing the accuracy of time management.
- Updated the logic to handle expected and actual records, allowing for better visibility of discrepancies in worked hours.
- Enhanced the table rendering to display comparative balances, improving user experience and clarity in time tracking.
This commit is contained in:
2025-11-23 08:29:38 -03:00
parent dfc975cb8f
commit e0b01cff0a

View File

@@ -596,6 +596,149 @@
return saldos;
}
/**
* Calcula saldos comparativos por par entrada/saída
* Compara horários reais com horários esperados configurados
* Retorna mapa com saldo trabalhado, esperado e diferença
*/
function calcularSaldoComparativoPorPar(
registros: Array<{ tipo: string; hora: number; minuto: number }>,
config: {
horarioEntrada: string;
horarioSaidaAlmoco: string;
horarioRetornoAlmoco: string;
horarioSaida: string;
}
): Map<number, {
trabalhadoMinutos: number;
trabalhadoHoras: number;
trabalhadoMinutosResto: number;
esperadoMinutos: number;
esperadoHoras: number;
esperadoMinutosResto: number;
diferencaMinutos: number;
diferencaHoras: number;
diferencaMinutosResto: number;
parIndex: number;
tamanhoPar: number;
}> {
const saldos = new Map<number, {
trabalhadoMinutos: number;
trabalhadoHoras: number;
trabalhadoMinutosResto: number;
esperadoMinutos: number;
esperadoHoras: number;
esperadoMinutosResto: number;
diferencaMinutos: number;
diferencaHoras: number;
diferencaMinutosResto: number;
parIndex: number;
tamanhoPar: number;
}>();
if (registros.length === 0) return saldos;
// Parsear horários esperados da configuração
const [horaEntradaEsperada, minutoEntradaEsperado] = config.horarioEntrada.split(':').map(Number);
const [horaSaidaAlmocoEsperada, minutoSaidaAlmocoEsperado] = config.horarioSaidaAlmoco.split(':').map(Number);
const [horaRetornoAlmocoEsperado, minutoRetornoAlmocoEsperado] = config.horarioRetornoAlmoco.split(':').map(Number);
const [horaSaidaEsperada, minutoSaidaEsperado] = config.horarioSaida.split(':').map(Number);
// Ordenar registros por hora e minuto
const registrosOrdenados = [...registros].sort((a, b) => {
if (a.hora !== b.hora) {
return a.hora - b.hora;
}
return a.minuto - b.minuto;
});
let parIndex = 0;
let entradaAtual: { tipo: string; hora: number; minuto: number; index: number } | null = null;
let indicesPar: number[] = [];
for (let i = 0; i < registrosOrdenados.length; i++) {
const reg = registrosOrdenados[i];
// Identificar início de um par (entrada ou retorno_almoco)
if (reg.tipo === 'entrada' || reg.tipo === 'retorno_almoco') {
// Se havia um par anterior incompleto, limpar
if (entradaAtual && indicesPar.length > 0) {
indicesPar = [];
}
entradaAtual = { ...reg, index: i };
indicesPar = [i];
}
// Identificar fim de um par (saida_almoco ou saida)
else if ((reg.tipo === 'saida_almoco' || reg.tipo === 'saida') && entradaAtual) {
indicesPar.push(i);
// Calcular tempo trabalhado real (saída - entrada)
const minutosEntradaReal = entradaAtual.hora * 60 + entradaAtual.minuto;
const minutosSaidaReal = reg.hora * 60 + reg.minuto;
let trabalhadoMinutos = minutosSaidaReal - minutosEntradaReal;
if (trabalhadoMinutos < 0) {
trabalhadoMinutos += 24 * 60;
}
// Calcular tempo esperado baseado no tipo de par
let esperadoMinutos: number;
if (entradaAtual.tipo === 'entrada') {
// Par 1: entrada -> saida_almoco
const minutosEntradaEsperada = horaEntradaEsperada * 60 + minutoEntradaEsperado;
const minutosSaidaEsperada = horaSaidaAlmocoEsperada * 60 + minutoSaidaAlmocoEsperado;
esperadoMinutos = minutosSaidaEsperada - minutosEntradaEsperada;
if (esperadoMinutos < 0) {
esperadoMinutos += 24 * 60;
}
} else {
// Par 2: retorno_almoco -> saida
const minutosEntradaEsperada = horaRetornoAlmocoEsperado * 60 + minutoRetornoAlmocoEsperado;
const minutosSaidaEsperada = horaSaidaEsperada * 60 + minutoSaidaEsperado;
esperadoMinutos = minutosSaidaEsperada - minutosEntradaEsperada;
if (esperadoMinutos < 0) {
esperadoMinutos += 24 * 60;
}
}
// Calcular diferença (trabalhado - esperado)
const diferencaMinutos = trabalhadoMinutos - esperadoMinutos;
// Converter para horas e minutos
const trabalhadoHoras = Math.floor(trabalhadoMinutos / 60);
const trabalhadoMinutosResto = trabalhadoMinutos % 60;
const esperadoHoras = Math.floor(esperadoMinutos / 60);
const esperadoMinutosResto = esperadoMinutos % 60;
const diferencaHoras = Math.floor(Math.abs(diferencaMinutos) / 60);
const diferencaMinutosResto = Math.abs(diferencaMinutos) % 60;
// Associar saldo a todos os registros do par
for (const idx of indicesPar) {
saldos.set(idx, {
trabalhadoMinutos,
trabalhadoHoras,
trabalhadoMinutosResto,
esperadoMinutos,
esperadoHoras,
esperadoMinutosResto,
diferencaMinutos,
diferencaHoras,
diferencaMinutosResto,
parIndex,
tamanhoPar: indicesPar.length
});
}
parIndex++;
entradaAtual = null;
indicesPar = [];
}
}
return saldos;
}
/**
* Gera array de todas as datas do período selecionado
*/
@@ -868,9 +1011,17 @@
if (sections.alteracoesGestor) {
try {
homologacoes = await client.query(api.pontos.listarHomologacoes, {
const todasHomologacoes = await client.query(api.pontos.listarHomologacoes, {
funcionarioId,
}) || [];
// Filtrar homologações pelo período selecionado
const dataInicioTimestamp = new Date(dataInicio + 'T00:00:00').getTime();
const dataFimTimestamp = new Date(dataFim + 'T23:59:59').getTime();
homologacoes = todasHomologacoes.filter((h) => {
return h.criadoEm >= dataInicioTimestamp && h.criadoEm <= dataFimTimestamp;
});
} catch (error) {
console.warn('Erro ao buscar homologações:', error);
// Continuar mesmo se houver erro ao buscar homologações
@@ -879,10 +1030,22 @@
if (sections.dispensasRegistro) {
try {
dispensas = await client.query(api.pontos.listarDispensas, {
const todasDispensas = await client.query(api.pontos.listarDispensas, {
funcionarioId,
apenasAtivas: false,
}) || [];
// Filtrar dispensas que têm interseção com o período selecionado
const dataInicioPeriodo = new Date(dataInicio + 'T00:00:00');
const dataFimPeriodo = new Date(dataFim + 'T23:59:59');
dispensas = todasDispensas.filter((d) => {
const dispensaInicio = new Date(d.dataInicio + 'T00:00:00');
const dispensaFim = new Date(d.dataFim + 'T23:59:59');
// Verificar se há interseção entre os períodos
return dispensaInicio <= dataFimPeriodo && dispensaFim >= dataInicioPeriodo;
});
} catch (error) {
console.warn('Erro ao buscar dispensas:', error);
// Continuar mesmo se houver erro ao buscar dispensas
@@ -890,7 +1053,11 @@
}
// Variável para armazenar saldos diários (usada no resumo do banco de horas)
const saldosDiariosPorData: Record<string, number> = {};
const saldosDiariosPorData: Record<string, {
diferencaMinutos: number;
trabalhadoMinutos: number;
esperadoMinutos: number;
}> = {};
// Tabela de registros
if (sections.registrosPonto) {
@@ -990,42 +1157,134 @@
return a.minuto - b.minuto;
});
// Calcular saldos por par entrada/saída (apenas com registros reais)
// Calcular saldos comparativos por par entrada/saída (apenas com registros reais)
const regsReaisOrdenados = [...regsReais].sort((a, b) => {
if (a.hora !== b.hora) return a.hora - b.hora;
return a.minuto - b.minuto;
});
const saldosPorPar = calcularSaldosPorPar(regsReaisOrdenados);
const saldosComparativosPorPar = calcularSaldoComparativoPorPar(regsReaisOrdenados, config);
// Calcular saldos esperados para pares incompletos ou dias sem registros
const saldosEsperadosPorPar: Map<number, { saldoMinutos: number; horas: number; minutos: number; tamanhoPar: number; incompleto: boolean }> = new Map();
const saldosEsperadosPorPar: Map<number, {
trabalhadoMinutos: number;
trabalhadoHoras: number;
trabalhadoMinutosResto: number;
esperadoMinutos: number;
esperadoHoras: number;
esperadoMinutosResto: number;
diferencaMinutos: number;
diferencaHoras: number;
diferencaMinutosResto: number;
tamanhoPar: number;
incompleto: boolean;
}> = new Map();
// Calcular saldo diário total (diferença acumulada de todos os pares)
// Declarar variáveis ANTES de usá-las
let saldoDiarioTotalDiferencaMinutos = 0;
let saldoDiarioTotalTrabalhadoMinutos = 0;
let saldoDiarioTotalEsperadoMinutos = 0;
// Criar conjunto de chaves de registros que já foram processados como completos
// Isso será usado tanto para evitar criar pares incompletos quanto para evitar somar duplicados
const chavesProcessadasCompletas = new Set<string>();
const paresCompletosProcessados = new Set<number>(); // Rastrear parIndex já processados
// Criar conjunto de chaves e somar saldos dos pares completos
// IMPORTANTE: saldosComparativosPorPar associa o saldo a AMBOS os registros do par (entrada + saída)
// Então precisamos garantir que somamos apenas UMA VEZ por par, não uma vez por registro
for (const [index, saldo] of saldosComparativosPorPar.entries()) {
const regEntrada = regsReaisOrdenados[index];
if (regEntrada) {
// Verificar se este parIndex já foi processado
if (!paresCompletosProcessados.has(saldo.parIndex)) {
// Primeira vez processando este par, somar o saldo
saldoDiarioTotalDiferencaMinutos += saldo.diferencaMinutos;
saldoDiarioTotalTrabalhadoMinutos += saldo.trabalhadoMinutos;
saldoDiarioTotalEsperadoMinutos += saldo.esperadoMinutos;
paresCompletosProcessados.add(saldo.parIndex);
}
// Adicionar chaves para evitar processamento duplicado depois
const chaveEntrada = `${regEntrada.tipo}-${regEntrada.hora}-${regEntrada.minuto}`;
chavesProcessadasCompletas.add(chaveEntrada);
// Encontrar saída correspondente
const tipoSaidaEsperado = regEntrada.tipo === 'entrada' ? 'saida_almoco' : 'saida';
for (let j = index + 1; j < regsReaisOrdenados.length; j++) {
const regSaida = regsReaisOrdenados[j];
if (regSaida && regSaida.tipo === tipoSaidaEsperado) {
const chaveSaida = `${regSaida.tipo}-${regSaida.hora}-${regSaida.minuto}`;
chavesProcessadasCompletas.add(chaveSaida);
break;
}
}
}
}
// Identificar pares incompletos e calcular saldos esperados
// IMPORTANTE: Só processar pares que NÃO foram processados como completos
for (let i = 0; i < todosRegistros.length; i++) {
const reg = todosRegistros[i];
// Se é entrada ou retorno_almoco (início de par) e é real
if ((reg.tipo === 'entrada' || reg.tipo === 'retorno_almoco') && reg.real) {
// Verificar se este registro já foi processado como par completo
const chaveRegistro = `${reg.tipo}-${reg.hora}-${reg.minuto}`;
if (chavesProcessadasCompletas.has(chaveRegistro)) {
continue; // Já foi processado como par completo, pular
}
// Verificar se há saída correspondente real
const tipoSaidaEsperado = reg.tipo === 'entrada' ? 'saida_almoco' : 'saida';
const saidaEncontrada = regsReais.find(r => r.tipo === tipoSaidaEsperado);
if (!saidaEncontrada) {
// Par incompleto: calcular saldo esperado baseado na configuração
const regEsperado = regsEsperados.find(r => r.tipo === tipoSaidaEsperado);
if (regEsperado) {
const minutosEntrada = reg.hora * 60 + reg.minuto;
const minutosSaidaEsperada = regEsperado.hora * 60 + regEsperado.minuto;
let saldoMinutos = minutosSaidaEsperada - minutosEntrada;
if (saldoMinutos < 0) {
saldoMinutos += 24 * 60;
// Se há saída correspondente, verificar se ela também está em chavesProcessadasCompletas
// Se estiver, significa que este par já foi processado como completo
if (saidaEncontrada) {
const chaveSaida = `${saidaEncontrada.tipo}-${saidaEncontrada.hora}-${saidaEncontrada.minuto}`;
if (chavesProcessadasCompletas.has(chaveSaida)) {
continue; // Par completo já processado, pular
}
}
const horas = Math.floor(saldoMinutos / 60);
const minutos = saldoMinutos % 60;
if (!saidaEncontrada) {
// Par incompleto: entrada real sem saída correspondente
// NÃO calcular tempo trabalhado aqui porque não há saída marcada
// O tempo trabalhado será 0, e a diferença será negativa (0 - esperado)
const regEsperado = regsEsperados.find(r => r.tipo === tipoSaidaEsperado);
if (regEsperado) {
// Tempo trabalhado = 0 (não há saída marcada, então não podemos assumir tempo trabalhado)
const trabalhadoMinutos = 0;
// Calcular tempo esperado
let esperadoMinutos: number;
if (reg.tipo === 'entrada') {
const [horaEntradaEsperada, minutoEntradaEsperado] = config.horarioEntrada.split(':').map(Number);
const [horaSaidaAlmocoEsperada, minutoSaidaAlmocoEsperado] = config.horarioSaidaAlmoco.split(':').map(Number);
const minutosEntradaEsperada = horaEntradaEsperada * 60 + minutoEntradaEsperado;
const minutosSaidaEsperadaConfig = horaSaidaAlmocoEsperada * 60 + minutoSaidaAlmocoEsperado;
esperadoMinutos = minutosSaidaEsperadaConfig - minutosEntradaEsperada;
if (esperadoMinutos < 0) esperadoMinutos += 24 * 60;
} else {
const [horaRetornoAlmocoEsperado, minutoRetornoAlmocoEsperado] = config.horarioRetornoAlmoco.split(':').map(Number);
const [horaSaidaEsperada, minutoSaidaEsperado] = config.horarioSaida.split(':').map(Number);
const minutosEntradaEsperada = horaRetornoAlmocoEsperado * 60 + minutoRetornoAlmocoEsperado;
const minutosSaidaEsperadaConfig = horaSaidaEsperada * 60 + minutoSaidaEsperado;
esperadoMinutos = minutosSaidaEsperadaConfig - minutosEntradaEsperada;
if (esperadoMinutos < 0) esperadoMinutos += 24 * 60;
}
// Calcular diferença (0 - esperado = negativo)
const diferencaMinutos = -esperadoMinutos;
const trabalhadoHoras = 0;
const trabalhadoMinutosResto = 0;
const esperadoHoras = Math.floor(esperadoMinutos / 60);
const esperadoMinutosResto = esperadoMinutos % 60;
const diferencaHoras = Math.floor(Math.abs(diferencaMinutos) / 60);
const diferencaMinutosResto = Math.abs(diferencaMinutos) % 60;
// Contar quantos registros fazem parte deste par na lista todosRegistros
// Encontrar índice do registro na lista ordenada todosRegistros
const indexNaListaTodos = todosRegistros.findIndex(
(r, idx) => idx >= i && r.tipo === reg.tipo && r.hora === reg.hora && r.minuto === reg.minuto
);
@@ -1041,9 +1300,15 @@
if (indexNaListaTodos >= 0) {
saldosEsperadosPorPar.set(indexNaListaTodos, {
saldoMinutos,
horas,
minutos,
trabalhadoMinutos,
trabalhadoHoras,
trabalhadoMinutosResto,
esperadoMinutos,
esperadoHoras,
esperadoMinutosResto,
diferencaMinutos,
diferencaHoras,
diferencaMinutosResto,
tamanhoPar,
incompleto: true
});
@@ -1053,10 +1318,117 @@
}
}
// Calcular saldo diário total (soma de todos os pares reais)
let saldoDiarioTotalMinutos = 0;
for (const saldo of saldosPorPar.values()) {
saldoDiarioTotalMinutos += saldo.saldoMinutos;
// Identificar pares completamente não marcados (quando há registros reais mas um par completo não foi marcado)
if (regsReais.length > 0) {
for (let i = 0; i < todosRegistros.length; i++) {
const reg = todosRegistros[i];
// Se é entrada ou retorno_almoco (início de par) e NÃO é real
if ((reg.tipo === 'entrada' || reg.tipo === 'retorno_almoco') && !reg.real) {
// Verificar se a saída correspondente também não foi marcada
const tipoSaidaEsperado = reg.tipo === 'entrada' ? 'saida_almoco' : 'saida';
const saidaEsperada = todosRegistros.find(
(r, idx) => idx > i && r.tipo === tipoSaidaEsperado && !r.real
);
if (saidaEsperada) {
// Par completamente não marcado: calcular saldo negativo
// Tempo trabalhado = 0, tempo esperado = configurado, diferença = -esperado
let esperadoMinutos: number;
if (reg.tipo === 'entrada') {
const [horaEntradaEsperada, minutoEntradaEsperado] = config.horarioEntrada.split(':').map(Number);
const [horaSaidaAlmocoEsperada, minutoSaidaAlmocoEsperado] = config.horarioSaidaAlmoco.split(':').map(Number);
const minutosEntradaEsperada = horaEntradaEsperada * 60 + minutoEntradaEsperado;
const minutosSaidaEsperadaConfig = horaSaidaAlmocoEsperada * 60 + minutoSaidaAlmocoEsperado;
esperadoMinutos = minutosSaidaEsperadaConfig - minutosEntradaEsperada;
if (esperadoMinutos < 0) esperadoMinutos += 24 * 60;
} else {
const [horaRetornoAlmocoEsperado, minutoRetornoAlmocoEsperado] = config.horarioRetornoAlmoco.split(':').map(Number);
const [horaSaidaEsperada, minutoSaidaEsperado] = config.horarioSaida.split(':').map(Number);
const minutosEntradaEsperada = horaRetornoAlmocoEsperado * 60 + minutoRetornoAlmocoEsperado;
const minutosSaidaEsperadaConfig = horaSaidaEsperada * 60 + minutoSaidaEsperado;
esperadoMinutos = minutosSaidaEsperadaConfig - minutosEntradaEsperada;
if (esperadoMinutos < 0) esperadoMinutos += 24 * 60;
}
// Trabalhado = 0, diferença = -esperado
const trabalhadoMinutos = 0;
const diferencaMinutos = -esperadoMinutos;
const trabalhadoHoras = 0;
const trabalhadoMinutosResto = 0;
const esperadoHoras = Math.floor(esperadoMinutos / 60);
const esperadoMinutosResto = esperadoMinutos % 60;
const diferencaHoras = Math.floor(Math.abs(diferencaMinutos) / 60);
const diferencaMinutosResto = Math.abs(diferencaMinutos) % 60;
// Encontrar índice da saída esperada na lista
const indexSaidaEsperada = todosRegistros.findIndex(
(r, idx) => idx > i && r.tipo === tipoSaidaEsperado && !r.real
);
// Associar saldo negativo ao início do par (entrada)
saldosEsperadosPorPar.set(i, {
trabalhadoMinutos,
trabalhadoHoras,
trabalhadoMinutosResto,
esperadoMinutos,
esperadoHoras,
esperadoMinutosResto,
diferencaMinutos,
diferencaHoras,
diferencaMinutosResto,
tamanhoPar: indexSaidaEsperada >= 0 ? 2 : 1, // entrada + saída
incompleto: false // Par completo não marcado
});
}
}
}
}
// NOTA: Os saldos dos pares completos já foram somados acima quando criamos chavesProcessadasCompletas
// As variáveis saldoDiarioTotal* já foram declaradas e inicializadas acima
// Somar saldos dos pares completamente não marcados e incompletos
// IMPORTANTE: Não somar pares que já foram processados como completos acima
// Usar o conjunto chavesProcessadasCompletas criado anteriormente
for (const [indexNaListaTodos, saldo] of saldosEsperadosPorPar.entries()) {
const regNaListaTodos = todosRegistros[indexNaListaTodos];
// Verificar se este registro real já foi processado como par completo
if (regNaListaTodos && regNaListaTodos.real) {
const chaveRegistro = `${regNaListaTodos.tipo}-${regNaListaTodos.hora}-${regNaListaTodos.minuto}`;
if (chavesProcessadasCompletas.has(chaveRegistro)) {
// Este registro está em chavesProcessadasCompletas
// Verificar se há uma saída correspondente que também está lá
// Se ambas estão, significa que o par completo já foi processado
const tipoSaidaEsperado = regNaListaTodos.tipo === 'entrada' ? 'saida_almoco' : 'saida';
// Procurar saída correspondente em regsReais que também está em chavesProcessadasCompletas
const saidaCompletaEncontrada = regsReais.find(r => {
if (r.tipo !== tipoSaidaEsperado) return false;
const chaveSaida = `${r.tipo}-${r.hora}-${r.minuto}`;
return chavesProcessadasCompletas.has(chaveSaida);
});
if (saidaCompletaEncontrada) {
continue; // Par completo já processado (entrada + saída estão em chavesProcessadasCompletas), não somar novamente
}
}
}
if (saldo.trabalhadoMinutos === 0 && !saldo.incompleto) {
// Par completamente não marcado: adicionar diferença negativa
saldoDiarioTotalDiferencaMinutos += saldo.diferencaMinutos;
saldoDiarioTotalTrabalhadoMinutos += saldo.trabalhadoMinutos; // 0
saldoDiarioTotalEsperadoMinutos += saldo.esperadoMinutos;
} else if (saldo.incompleto) {
// Par incompleto: entrada real sem saída correspondente
// Só somar se não foi processado como completo acima
saldoDiarioTotalDiferencaMinutos += saldo.diferencaMinutos;
saldoDiarioTotalTrabalhadoMinutos += saldo.trabalhadoMinutos;
saldoDiarioTotalEsperadoMinutos += saldo.esperadoMinutos;
}
}
// Se não há registros reais, calcular saldo esperado baseado na configuração
@@ -1079,10 +1451,17 @@
let saldoPar2 = minutosPar2Saida - minutosPar2Entrada;
if (saldoPar2 < 0) saldoPar2 += 24 * 60;
saldoDiarioTotalMinutos = saldoPar1 + saldoPar2;
saldoDiarioTotalTrabalhadoMinutos = 0; // Nenhum tempo trabalhado
saldoDiarioTotalEsperadoMinutos = saldoPar1 + saldoPar2;
saldoDiarioTotalDiferencaMinutos = -saldoDiarioTotalEsperadoMinutos; // Diferença negativa (0 - esperado)
}
saldosDiariosPorData[data] = saldoDiarioTotalMinutos;
// Armazenar saldo diário completo (usado no resumo do banco de horas)
saldosDiariosPorData[data] = {
diferencaMinutos: saldoDiarioTotalDiferencaMinutos,
trabalhadoMinutos: saldoDiarioTotalTrabalhadoMinutos,
esperadoMinutos: saldoDiarioTotalEsperadoMinutos
};
// Criar linhas da tabela
for (let i = 0; i < todosRegistros.length; i++) {
@@ -1105,7 +1484,7 @@
linha._naoMarcado = true;
}
// Saldo Diário por par entrada/saída
// Saldo Diário por par entrada/saída com cálculo comparativo
if (sections.saldoDiario) {
const isInicioPar = reg.tipo === 'entrada' || reg.tipo === 'retorno_almoco';
@@ -1117,17 +1496,23 @@
// Verificar se há saldo esperado (par incompleto)
const saldoEsperado = saldosEsperadosPorPar.get(i);
if (saldoEsperado) {
// Par incompleto: mostrar saldo esperado em vermelho
// Par incompleto: mostrar saldo comparativo em vermelho
linha._saldoVermelho = true;
const sinalDiferenca = saldoEsperado.diferencaMinutos >= 0 ? '+' : '-';
linha.push({
content: `+${saldoEsperado.horas}h ${saldoEsperado.minutos}min`,
content: `+${saldoEsperado.trabalhadoHoras}h ${saldoEsperado.trabalhadoMinutosResto}min / ${sinalDiferenca}${saldoEsperado.diferencaHoras}h ${saldoEsperado.diferencaMinutosResto}min`,
rowSpan: saldoEsperado.tamanhoPar
});
} else if (indexReal >= 0) {
const saldoPar = saldosPorPar.get(indexReal);
const saldoPar = saldosComparativosPorPar.get(indexReal);
if (saldoPar) {
const sinalDiferenca = saldoPar.diferencaMinutos >= 0 ? '+' : '-';
// Aplicar cor vermelha se diferença for negativa
if (saldoPar.diferencaMinutos < 0) {
linha._saldoVermelho = true;
}
linha.push({
content: `+${saldoPar.horas}h ${saldoPar.minutos}min`,
content: `+${saldoPar.trabalhadoHoras}h ${saldoPar.trabalhadoMinutosResto}min / ${sinalDiferenca}${saldoPar.diferencaHoras}h ${saldoPar.diferencaMinutosResto}min`,
rowSpan: saldoPar.tamanhoPar
});
} else {
@@ -1152,14 +1537,24 @@
// Verificar se há saldo esperado para este par
const saldoEsperado = saldosEsperadosPorPar.get(i);
if (saldoEsperado) {
// Par incompleto: mostrar saldo esperado em vermelho
// Par incompleto ou completamente não marcado: mostrar saldo em vermelho
linha._saldoVermelho = true;
const sinalDiferenca = saldoEsperado.diferencaMinutos >= 0 ? '+' : '-';
// Se par completamente não marcado (trabalhado = 0), mostrar apenas diferença negativa
if (saldoEsperado.trabalhadoMinutos === 0) {
linha.push({
content: `+${saldoEsperado.horas}h ${saldoEsperado.minutos}min`,
content: `+0h 0min / ${sinalDiferenca}${saldoEsperado.diferencaHoras}h ${saldoEsperado.diferencaMinutosResto}min`,
rowSpan: saldoEsperado.tamanhoPar
});
} else {
linha.push({
content: `+${saldoEsperado.trabalhadoHoras}h ${saldoEsperado.trabalhadoMinutosResto}min / ${sinalDiferenca}${saldoEsperado.diferencaHoras}h ${saldoEsperado.diferencaMinutosResto}min`,
rowSpan: saldoEsperado.tamanhoPar
});
}
} else if (regsReais.length === 0) {
// Dia sem registros: calcular saldo esperado completo
// Dia sem registros: calcular saldo esperado completo com diferença negativa
if (reg.tipo === 'entrada') {
// Par 1 completo esperado
const [horaEntrada, minutoEntrada] = config.horarioEntrada.split(':').map(Number);
@@ -1171,8 +1566,9 @@
const horas = Math.floor(saldoMinutos / 60);
const minutos = saldoMinutos % 60;
linha._saldoVermelho = true;
// Para dia sem registros, mostrar 0h trabalhado e diferença negativa
linha.push({
content: `+${horas}h ${minutos}min`,
content: `+0h 0min / -${horas}h ${minutos}min`,
rowSpan: 2 // entrada + saida_almoco
});
} else if (reg.tipo === 'retorno_almoco') {
@@ -1186,20 +1582,54 @@
const horas = Math.floor(saldoMinutos / 60);
const minutos = saldoMinutos % 60;
linha._saldoVermelho = true;
// Para dia sem registros, mostrar 0h trabalhado e diferença negativa
linha.push({
content: `+${horas}h ${minutos}min`,
content: `+0h 0min / -${horas}h ${minutos}min`,
rowSpan: 2 // retorno_almoco + saida
});
} else {
linha.push('-');
}
} else {
// Há registros reais mas este par não foi marcado completamente
// Verificar se é um par completamente não marcado
const tipoSaidaEsperado = reg.tipo === 'entrada' ? 'saida_almoco' : 'saida';
const saidaEsperadaExiste = todosRegistros.some(
(r, idx) => idx > i && r.tipo === tipoSaidaEsperado && !r.real
);
if (saidaEsperadaExiste) {
// Par completamente não marcado: calcular saldo negativo
const saldoEsperadoCompleto = saldosEsperadosPorPar.get(i);
if (saldoEsperadoCompleto) {
linha._saldoVermelho = true;
const sinalDiferenca = saldoEsperadoCompleto.diferencaMinutos >= 0 ? '+' : '-';
linha.push({
content: `+0h 0min / ${sinalDiferenca}${saldoEsperadoCompleto.diferencaHoras}h ${saldoEsperadoCompleto.diferencaMinutosResto}min`,
rowSpan: saldoEsperadoCompleto.tamanhoPar
});
} else {
linha.push('-');
}
} else {
linha.push('-');
}
}
} else {
// Saída sem entrada correspondente: não tem saldo
// Saída não marcada: verificar se faz parte de um par completamente não marcado
// Se a entrada correspondente também não foi marcada, o saldo já foi adicionado na linha da entrada
// Então apenas não adicionar nada aqui (será coberto pelo rowspan)
const tipoEntradaEsperado = reg.tipo === 'saida_almoco' ? 'entrada' : 'retorno_almoco';
const entradaEsperadaExiste = todosRegistros.some(
(r, idx) => idx < i && r.tipo === tipoEntradaEsperado && !r.real
);
if (!entradaEsperadaExiste) {
// Saída sem entrada correspondente esperada: não tem saldo
linha.push('-');
}
// Se entrada esperada existe, o saldo já foi adicionado com rowspan na linha da entrada
}
}
}
@@ -1269,12 +1699,48 @@
funcionarioId,
});
// Calcular saldo do período selecionado baseado nos saldos diários calculados
let saldoPeriodoMinutos = 0;
// Calcular total de dias do período selecionado
const diasPeriodo = gerarDiasPeriodo(dataInicio, dataFim);
const totalDiasPeriodo = diasPeriodo.length;
// Calcular carga horária diária esperada baseada na configuração
const [horaEntradaConfig, minutoEntradaConfig] = config.horarioEntrada.split(':').map(Number);
const [horaSaidaAlmocoConfig, minutoSaidaAlmocoConfig] = config.horarioSaidaAlmoco.split(':').map(Number);
const [horaRetornoAlmocoConfig, minutoRetornoAlmocoConfig] = config.horarioRetornoAlmoco.split(':').map(Number);
const [horaSaidaConfig, minutoSaidaConfig] = config.horarioSaida.split(':').map(Number);
// Par 1: entrada -> saida_almoco
const minutosPar1EsperadoConfig = (horaSaidaAlmocoConfig * 60 + minutoSaidaAlmocoConfig) - (horaEntradaConfig * 60 + minutoEntradaConfig);
const minutosPar1EsperadoAjustadoConfig = minutosPar1EsperadoConfig < 0 ? minutosPar1EsperadoConfig + 24 * 60 : minutosPar1EsperadoConfig;
// Par 2: retorno_almoco -> saida
const minutosPar2EsperadoConfig = (horaSaidaConfig * 60 + minutoSaidaConfig) - (horaRetornoAlmocoConfig * 60 + minutoRetornoAlmocoConfig);
const minutosPar2EsperadoAjustadoConfig = minutosPar2EsperadoConfig < 0 ? minutosPar2EsperadoConfig + 24 * 60 : minutosPar2EsperadoConfig;
const cargaHorariaDiariaEsperadaMinutos = minutosPar1EsperadoAjustadoConfig + minutosPar2EsperadoAjustadoConfig;
// Calcular saldos do período selecionado baseado nos saldos diários calculados
let saldoPeriodoDiferencaMinutos = 0;
let saldoPeriodoTrabalhadoMinutos = 0;
let diasComSaldoPositivo = 0;
let diasComSaldoNegativo = 0;
let diasSemRegistros = 0;
if (sections.registrosPonto && Object.keys(saldosDiariosPorData).length > 0) {
// Somar todos os saldos diários do período
for (const saldoMinutos of Object.values(saldosDiariosPorData)) {
saldoPeriodoMinutos += saldoMinutos;
for (const saldo of Object.values(saldosDiariosPorData)) {
saldoPeriodoDiferencaMinutos += saldo.diferencaMinutos;
saldoPeriodoTrabalhadoMinutos += saldo.trabalhadoMinutos;
if (saldo.diferencaMinutos > 0) {
diasComSaldoPositivo++;
} else if (saldo.diferencaMinutos < 0) {
diasComSaldoNegativo++;
}
if (saldo.trabalhadoMinutos === 0 && saldo.esperadoMinutos > 0) {
diasSemRegistros++;
}
}
} else {
// Fallback: calcular a partir dos registros se não tiver saldos diários
@@ -1295,19 +1761,50 @@
for (const regs of Object.values(registrosPorDataPeriodo)) {
const saldoDiario = calcularSaldoDiario(regs);
if (saldoDiario) {
saldoPeriodoMinutos += saldoDiario.saldoMinutos;
saldoPeriodoDiferencaMinutos += saldoDiario.saldoMinutos;
saldoPeriodoTrabalhadoMinutos += saldoDiario.saldoMinutos;
}
}
}
const horasPeriodo = Math.floor(Math.abs(saldoPeriodoMinutos) / 60);
const minutosPeriodo = Math.abs(saldoPeriodoMinutos) % 60;
const sinalPeriodo = saldoPeriodoMinutos >= 0 ? '+' : '-';
const saldoPeriodoFormatado = `${sinalPeriodo}${horasPeriodo}h ${minutosPeriodo}min`;
// Calcular saldo esperado do período: carga horária diária × número de dias
const saldoPeriodoEsperadoMinutos = cargaHorariaDiariaEsperadaMinutos * totalDiasPeriodo;
// Calcular total de dias do período selecionado
const diasPeriodo = gerarDiasPeriodo(dataInicio, dataFim);
const totalDiasPeriodo = diasPeriodo.length;
// Calcular médias diárias
const mediaDiariaTrabalhadaHoras = totalDiasPeriodo > 0 ? Math.floor(saldoPeriodoTrabalhadoMinutos / 60 / totalDiasPeriodo) : 0;
const mediaDiariaTrabalhadaMinutos = totalDiasPeriodo > 0 ? Math.floor((saldoPeriodoTrabalhadoMinutos / totalDiasPeriodo) % 60) : 0;
// Calcular média esperada baseada na configuração padrão (não no período)
const [horaEntrada, minutoEntrada] = config.horarioEntrada.split(':').map(Number);
const [horaSaidaAlmoco, minutoSaidaAlmoco] = config.horarioSaidaAlmoco.split(':').map(Number);
const [horaRetornoAlmoco, minutoRetornoAlmoco] = config.horarioRetornoAlmoco.split(':').map(Number);
const [horaSaida, minutoSaida] = config.horarioSaida.split(':').map(Number);
// Par 1: entrada -> saida_almoco
const minutosPar1Esperado = (horaSaidaAlmoco * 60 + minutoSaidaAlmoco) - (horaEntrada * 60 + minutoEntrada);
const minutosPar1EsperadoAjustado = minutosPar1Esperado < 0 ? minutosPar1Esperado + 24 * 60 : minutosPar1Esperado;
// Par 2: retorno_almoco -> saida
const minutosPar2Esperado = (horaSaida * 60 + minutoSaida) - (horaRetornoAlmoco * 60 + minutoRetornoAlmoco);
const minutosPar2EsperadoAjustado = minutosPar2Esperado < 0 ? minutosPar2Esperado + 24 * 60 : minutosPar2Esperado;
const totalEsperadoDiarioMinutos = minutosPar1EsperadoAjustado + minutosPar2EsperadoAjustado;
const mediaDiariaEsperadaHoras = Math.floor(totalEsperadoDiarioMinutos / 60);
const mediaDiariaEsperadaMinutos = totalEsperadoDiarioMinutos % 60;
// Formatar valores
const horasPeriodoDiferenca = Math.floor(Math.abs(saldoPeriodoDiferencaMinutos) / 60);
const minutosPeriodoDiferenca = Math.abs(saldoPeriodoDiferencaMinutos) % 60;
const sinalPeriodoDiferenca = saldoPeriodoDiferencaMinutos >= 0 ? '+' : '-';
const saldoPeriodoDiferencaFormatado = `${sinalPeriodoDiferenca}${horasPeriodoDiferenca}h ${minutosPeriodoDiferenca}min`;
const horasPeriodoTrabalhado = Math.floor(saldoPeriodoTrabalhadoMinutos / 60);
const minutosPeriodoTrabalhado = saldoPeriodoTrabalhadoMinutos % 60;
const saldoPeriodoTrabalhadoFormatado = `+${horasPeriodoTrabalhado}h ${minutosPeriodoTrabalhado}min`;
const horasPeriodoEsperado = Math.floor(saldoPeriodoEsperadoMinutos / 60);
const minutosPeriodoEsperado = saldoPeriodoEsperadoMinutos % 60;
const saldoPeriodoEsperadoFormatado = `+${horasPeriodoEsperado}h ${minutosPeriodoEsperado}min`;
doc.setFontSize(12);
doc.setFont('helvetica', 'bold');
@@ -1324,20 +1821,45 @@
const sinal = saldoMinutos >= 0 ? '+' : '-';
const saldoFormatado = `${sinal}${horas}h ${minutos}min`;
// Preparar dados da tabela
const bancoHorasData: string[][] = [
// Calcular saldo acumulado de períodos anteriores
// Saldo Anterior = Saldo Total - Saldo do Período Atual
const saldoAnteriorMinutos = saldoMinutos - saldoPeriodoDiferencaMinutos;
const horasAnterior = Math.floor(Math.abs(saldoAnteriorMinutos) / 60);
const minutosAnterior = Math.abs(saldoAnteriorMinutos) % 60;
const sinalAnterior = saldoAnteriorMinutos >= 0 ? '+' : '-';
const saldoAnteriorFormatado = `${sinalAnterior}${horasAnterior}h ${minutosAnterior}min`;
// Calcular resultado final
const resultadoFinalMinutos = saldoAnteriorMinutos + saldoPeriodoDiferencaMinutos;
const horasResultadoFinal = Math.floor(Math.abs(resultadoFinalMinutos) / 60);
const minutosResultadoFinal = Math.abs(resultadoFinalMinutos) % 60;
const sinalResultadoFinal = resultadoFinalMinutos >= 0 ? '+' : '-';
const resultadoFinalFormatado = `${sinalResultadoFinal}${horasResultadoFinal}h ${minutosResultadoFinal}min`;
// Preparar dados da tabela com melhorias
const bancoHorasData: any[][] = [
['Saldo Atual', saldoFormatado],
['Saldo do Período Selecionado', saldoPeriodoFormatado]
['Saldo Banco Acumulado de Períodos Anteriores', saldoAnteriorFormatado],
['Saldo do Período Exibido', saldoPeriodoDiferencaFormatado],
['Resultado Final', resultadoFinalFormatado]
];
if (saldoMinutos > 0) {
bancoHorasData.push(['Horas Excedentes', `${horas}h ${minutos}min`]);
}
// Adicionar detalhamento
bancoHorasData.push(['', '']); // Linha separadora
bancoHorasData.push(['Saldo Trabalhado do Período', saldoPeriodoTrabalhadoFormatado]);
bancoHorasData.push(['Saldo Esperado do Período', saldoPeriodoEsperadoFormatado]);
bancoHorasData.push(['Diferença do Período', saldoPeriodoDiferencaFormatado]);
if (saldoMinutos < 0) {
bancoHorasData.push(['Horas a Pagar', `${horas}h ${minutos}min`]);
}
// Adicionar estatísticas
bancoHorasData.push(['', '']); // Linha separadora
bancoHorasData.push(['Média Diária de Horas Trabalhadas', `+${mediaDiariaTrabalhadaHoras}h ${mediaDiariaTrabalhadaMinutos}min`]);
bancoHorasData.push(['Média Diária Esperada', `+${mediaDiariaEsperadaHoras}h ${mediaDiariaEsperadaMinutos}min`]);
// Adicionar contagens
bancoHorasData.push(['', '']); // Linha separadora
bancoHorasData.push(['Dias com Saldo Positivo', `${diasComSaldoPositivo} dias`]);
bancoHorasData.push(['Dias com Saldo Negativo', `${diasComSaldoNegativo} dias`]);
bancoHorasData.push(['Dias sem Registros', `${diasSemRegistros} dias`]);
bancoHorasData.push(['Total de Dias do Período', `${totalDiasPeriodo} dias`]);
// Criar tabela no mesmo estilo das outras seções
@@ -1353,18 +1875,69 @@
1: { cellWidth: 'auto' }
},
didParseCell: function(data) {
// Ignorar linhas separadoras vazias
if (data.cell.text[0] === '' && data.column.index === 0) {
data.cell.styles.fillColor = [240, 240, 240]; // Cor de fundo cinza claro
return;
}
const campo = data.cell.text[0];
// Destacar "Saldo do Período Selecionado" em vermelho se negativo
if (campo === 'Saldo do Período Selecionado' && saldoPeriodoMinutos < 0) {
data.cell.styles.textColor = [200, 0, 0];
if (data.column.index === 1) {
const valor = data.cell.text[1] || '';
// Aplicar cores baseado no valor
if (data.column.index === 1 && valor) {
// Aplicar cor no Saldo Atual (vermelho para negativo, azul para positivo)
if (campo === 'Saldo Atual') {
if (saldoMinutos < 0) {
data.cell.styles.textColor = [200, 0, 0]; // Vermelho para negativo
} else if (saldoMinutos > 0) {
data.cell.styles.textColor = [0, 100, 200]; // Azul para positivo
}
data.cell.styles.fontStyle = 'bold';
}
// Verificar se o valor contém sinal negativo
if (valor.includes('-') && !valor.includes('±')) {
data.cell.styles.textColor = [200, 0, 0]; // Vermelho para negativo
if (campo === 'Saldo do Período Exibido' || campo === 'Diferença do Período' || campo === 'Resultado Final') {
data.cell.styles.fontStyle = 'bold';
}
// Destacar "Horas a Pagar" em vermelho se saldo negativo
if (campo === 'Horas a Pagar' && saldoMinutos < 0) {
} else if (valor.includes('+') || campo.includes('Trabalhado') || campo.includes('Esperado')) {
// Verde para valores positivos ou campos de trabalhado/esperado
if (campo.includes('Diferença') && saldoPeriodoDiferencaMinutos < 0) {
data.cell.styles.textColor = [200, 0, 0]; // Vermelho se diferença negativa
data.cell.styles.fontStyle = 'bold';
} else if (campo.includes('Diferença') && saldoPeriodoDiferencaMinutos > 0) {
data.cell.styles.textColor = [0, 128, 0]; // Verde se diferença positiva
data.cell.styles.fontStyle = 'bold';
} else if (campo.includes('Trabalhado') || campo.includes('Esperado')) {
data.cell.styles.textColor = [0, 128, 0]; // Verde para trabalhado/esperado
}
}
// Destacar campos específicos
if (campo === 'Dias com Saldo Negativo') {
data.cell.styles.textColor = [200, 0, 0];
if (data.column.index === 1) {
data.cell.styles.fontStyle = 'bold';
}
if (campo === 'Dias com Saldo Positivo') {
data.cell.styles.textColor = [0, 128, 0];
data.cell.styles.fontStyle = 'bold';
}
if (campo === 'Resultado Final') {
if (resultadoFinalMinutos < 0) {
data.cell.styles.textColor = [200, 0, 0];
} else if (resultadoFinalMinutos > 0) {
data.cell.styles.textColor = [0, 128, 0];
}
data.cell.styles.fontStyle = 'bold';
}
if (campo === 'Saldo do Período Exibido') {
if (saldoPeriodoDiferencaMinutos < 0) {
data.cell.styles.textColor = [200, 0, 0];
} else if (saldoPeriodoDiferencaMinutos > 0) {
data.cell.styles.textColor = [0, 128, 0];
}
data.cell.styles.fontStyle = 'bold';
}
}