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

- Added a new function `calcularSaldosPorPar` to compute balances for entry and exit pairs, returning a map of balances indexed by record.
- Updated the table data generation to display daily balances per pair, enhancing visibility of time management.
- Implemented logic to handle incomplete pairs and display appropriate messages in the UI, improving user experience.
This commit is contained in:
2025-11-23 05:52:01 -03:00
parent 095f041891
commit ac8e8f56b8

View File

@@ -527,6 +527,75 @@
};
}
/**
* Calcula saldos por par entrada/saída
* Retorna um mapa com o índice do registro e informações do saldo do par
*/
function calcularSaldosPorPar(registros: Array<{ tipo: string; hora: number; minuto: number }>): Map<number, { saldoMinutos: number; horas: number; minutos: number; parIndex: number; tamanhoPar: number }> {
const saldos = new Map<number, { saldoMinutos: number; horas: number; minutos: number; parIndex: number; tamanhoPar: number }>();
if (registros.length === 0) return saldos;
// 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 saldo do par (saída - entrada)
const minutosEntrada = entradaAtual.hora * 60 + entradaAtual.minuto;
const minutosSaida = reg.hora * 60 + reg.minuto;
let saldoMinutos = minutosSaida - minutosEntrada;
if (saldoMinutos < 0) {
saldoMinutos += 24 * 60; // Adicionar um dia em minutos
}
const horas = Math.floor(saldoMinutos / 60);
const minutos = saldoMinutos % 60;
// Associar saldo a todos os registros do par
for (const idx of indicesPar) {
saldos.set(idx, {
saldoMinutos,
horas,
minutos,
parIndex,
tamanhoPar: indicesPar.length
});
}
parIndex++;
entradaAtual = null;
indicesPar = [];
}
}
return saldos;
}
function abrirModalImpressao(funcionarioId: Id<'funcionarios'>) {
funcionarioParaImprimir = funcionarioId;
mostrarModalImpressao = true;
@@ -816,16 +885,25 @@
});
}
// Criar dados da tabela com saldo diário
// Criar dados da tabela com saldo diário por par
for (const [data, regs] of Object.entries(registrosPorData)) {
// Formatar data para exibição usando função centralizada (DD/MM/AAAA)
const dataFormatada = formatarDataDDMMAAAA(data);
// Calcular saldo diário como diferença entre saída e entrada
const saldoDiarioDia = calcularSaldoDiario(regs);
// Ordenar registros por hora e minuto para garantir ordem correta
const regsOrdenados = [...regs].sort((a, b) => {
if (a.hora !== b.hora) {
return a.hora - b.hora;
}
return a.minuto - b.minuto;
});
for (const reg of regs) {
const linha: string[] = [
// Calcular saldos por par entrada/saída
const saldosPorPar = calcularSaldosPorPar(regsOrdenados);
for (let i = 0; i < regsOrdenados.length; i++) {
const reg = regsOrdenados[i];
const linha: any[] = [
dataFormatada,
config
? getTipoRegistroLabel(reg.tipo as 'entrada' | 'saida_almoco' | 'retorno_almoco' | 'saida', {
@@ -838,42 +916,29 @@
formatarHoraPonto(reg.hora, reg.minuto),
];
// Saldo Diário sempre após Horário
// Saldo Diário por par entrada/saída
if (sections.saldoDiario) {
if (saldoDiarioDia) {
const sinal = saldoDiarioDia.positivo ? '+' : '-';
linha.push(`${sinal}${saldoDiarioDia.horas}h ${saldoDiarioDia.minutos}min`);
const saldoPar = saldosPorPar.get(i);
if (saldoPar) {
// Verificar se é o primeiro registro do par (entrada ou retorno_almoco)
const isInicioPar = reg.tipo === 'entrada' || reg.tipo === 'retorno_almoco';
if (isInicioPar) {
// Primeira linha do par: adicionar saldo com rowspan
linha.push({
content: `+${saldoPar.horas}h ${saldoPar.minutos}min`,
rowSpan: saldoPar.tamanhoPar
});
}
// Outras linhas do par (saida_almoco ou saida): não adicionar coluna (o rowspan cobre)
} else {
// Registro sem par completo: mostrar "-" em célula individual
linha.push('-');
}
}
// Adicionar localização (geofencing)
if (reg.dentroRaioPermitido === true) {
linha.push('✅ Dentro do Raio');
} else if (reg.dentroRaioPermitido === false) {
linha.push('⚠️ Fora do Raio');
} else {
linha.push('❓ Não Validado');
}
linha.push(reg.dentroDoPrazo ? 'Sim' : 'Não');
// Adicionar dados de acelerômetro (se disponível)
if (reg.acelerometroX !== undefined || reg.sensorDisponivel !== undefined) {
if (reg.sensorDisponivel === false && !reg.acelerometroX) {
linha.push('Sensor: Não disponível');
} else if (reg.acelerometroX !== undefined) {
const movimento = reg.movimentoDetectado ? 'Sim' : 'Não';
const magnitude = reg.magnitudeMovimento !== undefined ? reg.magnitudeMovimento.toFixed(2) : 'N/A';
linha.push(`Mov: ${movimento} | Mag: ${magnitude} m/s²`);
} else {
linha.push('Sensor: N/A');
}
} else {
linha.push('-');
}
tableData.push(linha);
}
}
@@ -882,9 +947,7 @@
if (sections.saldoDiario) {
headers.push('Saldo Diário');
}
headers.push('Localização');
headers.push('Dentro do Prazo');
headers.push('Acelerômetro');
// Salvar a posição Y antes da tabela
const yPosAntesTabela = yPosition;
@@ -923,15 +986,42 @@
funcionarioId,
});
// Calcular saldo do período selecionado
let saldoPeriodoMinutos = 0;
const registrosPorDataPeriodo: Record<string, Array<{ tipo: string; hora: number; minuto: number }>> = {};
for (const r of registrosFuncionario) {
const dataKey = r.data;
if (!registrosPorDataPeriodo[dataKey]) {
registrosPorDataPeriodo[dataKey] = [];
}
registrosPorDataPeriodo[dataKey]!.push({
tipo: r.tipo,
hora: r.hora,
minuto: r.minuto,
});
}
// Somar todos os saldos diários do período
for (const regs of Object.values(registrosPorDataPeriodo)) {
const saldoDiario = calcularSaldoDiario(regs);
if (saldoDiario) {
saldoPeriodoMinutos += 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`;
doc.setFontSize(12);
doc.setFont('helvetica', 'bold');
doc.setTextColor(41, 128, 185);
doc.text('RESUMO DO BANCO DE HORAS', 15, yPosition);
doc.setFont('helvetica', 'normal');
doc.setTextColor(0, 0, 0);
yPosition += 10;
doc.setFontSize(10);
if (bancoHoras) {
const saldoMinutos = bancoHoras.saldoAcumuladoMinutos;
@@ -940,40 +1030,81 @@
const sinal = saldoMinutos >= 0 ? '+' : '-';
const saldoFormatado = `${sinal}${horas}h ${minutos}min`;
doc.setFont('helvetica', 'bold');
doc.text('Saldo Atual:', 15, yPosition);
doc.setFont('helvetica', 'normal');
doc.text(saldoFormatado, 60, yPosition);
yPosition += 8;
// Preparar dados da tabela
const bancoHorasData: string[][] = [
['Saldo Atual', saldoFormatado],
['Saldo do Período Selecionado', saldoPeriodoFormatado]
];
if (saldoMinutos > 0) {
doc.setFont('helvetica', 'bold');
doc.setTextColor(0, 128, 0);
doc.text('Horas Excedentes:', 15, yPosition);
doc.setFont('helvetica', 'normal');
doc.text(`${horas}h ${minutos}min`, 75, yPosition);
doc.setTextColor(0, 0, 0);
yPosition += 8;
bancoHorasData.push(['Horas Excedentes', `${horas}h ${minutos}min`]);
}
if (saldoMinutos < 0) {
doc.setFont('helvetica', 'bold');
doc.setTextColor(200, 0, 0);
doc.text('Horas a Pagar:', 15, yPosition);
doc.setFont('helvetica', 'normal');
doc.text(`${horas}h ${minutos}min`, 70, yPosition);
doc.setTextColor(0, 0, 0);
yPosition += 8;
bancoHorasData.push(['Horas a Pagar', `${horas}h ${minutos}min`]);
}
doc.setFont('helvetica', 'bold');
doc.text('Total de Dias com Registro:', 15, yPosition);
doc.setFont('helvetica', 'normal');
doc.text(`${bancoHoras.totalDias} dias`, 95, yPosition);
yPosition += 10;
bancoHorasData.push(['Total de Dias com Registro', `${bancoHoras.totalDias} dias`]);
// Criar tabela no mesmo estilo das outras seções
autoTable(doc, {
startY: yPosition,
head: [['Campo', 'Valor']],
body: bancoHorasData,
theme: 'grid',
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
styles: { fontSize: 9 },
columnStyles: {
0: { fontStyle: 'bold', cellWidth: 80 },
1: { cellWidth: 'auto' }
},
didParseCell: function(data) {
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) {
data.cell.styles.fontStyle = 'bold';
}
}
// Destacar "Horas a Pagar" em vermelho se saldo negativo
if (campo === 'Horas a Pagar' && saldoMinutos < 0) {
data.cell.styles.textColor = [200, 0, 0];
if (data.column.index === 1) {
data.cell.styles.fontStyle = 'bold';
}
}
}
});
// Calcular posição Y após a tabela
const lastPage = doc.getNumberOfPages();
doc.setPage(lastPage);
const finalY = (doc as { lastAutoTable?: { finalY: number } }).lastAutoTable?.finalY;
if (finalY) {
yPosition = finalY + 10;
} else {
yPosition += bancoHorasData.length * 7 + 10;
}
} else {
doc.text('Banco de horas não disponível', 15, yPosition);
yPosition += 10;
// Se não houver banco de horas, criar tabela vazia com mensagem
autoTable(doc, {
startY: yPosition,
head: [['Campo', 'Valor']],
body: [['Banco de horas', 'Não disponível']],
theme: 'grid',
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
styles: { fontSize: 9 },
});
const lastPage = doc.getNumberOfPages();
doc.setPage(lastPage);
const finalY = (doc as { lastAutoTable?: { finalY: number } }).lastAutoTable?.finalY;
if (finalY) {
yPosition = finalY + 10;
} else {
yPosition += 20;
}
}
}