feat: enhance registro-pontos page with date range handling and expected record generation

- Introduced functions to generate all dates within a selected period and to create expected records based on user configuration.
- Updated the logic to process daily records, combining real and expected entries, and calculating daily balances.
- Enhanced table rendering to visually distinguish between marked and unmarked records, improving user experience and clarity in time management.
This commit is contained in:
2025-11-23 06:06:22 -03:00
parent ac8e8f56b8
commit dfc975cb8f

View File

@@ -596,6 +596,55 @@
return saldos;
}
/**
* Gera array de todas as datas do período selecionado
*/
function gerarDiasPeriodo(dataInicio: string, dataFim: string): string[] {
const dias: string[] = [];
const inicio = new Date(dataInicio);
const fim = new Date(dataFim);
for (let d = new Date(inicio); d <= fim; d.setDate(d.getDate() + 1)) {
dias.push(d.toISOString().split('T')[0]!);
}
return dias;
}
/**
* Gera registros esperados para um dia baseado na configuração
*/
function gerarRegistrosEsperados(data: string, config: {
horarioEntrada: string;
horarioSaidaAlmoco: string;
horarioRetornoAlmoco: string;
horarioSaida: string;
}): Array<{ tipo: string; hora: number; minuto: number; data: string }> {
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);
return [
{ tipo: 'entrada', hora: horaEntrada, minuto: minutoEntrada, data },
{ tipo: 'saida_almoco', hora: horaSaidaAlmoco, minuto: minutoSaidaAlmoco, data },
{ tipo: 'retorno_almoco', hora: horaRetornoAlmoco, minuto: minutoRetornoAlmoco, data },
{ tipo: 'saida', hora: horaSaida, minuto: minutoSaida, data },
];
}
/**
* Verifica se um registro esperado foi marcado
*/
function registroFoiMarcado(
registroEsperado: { tipo: string; hora: number; minuto: number },
registrosReais: Array<{ tipo: string; hora: number; minuto: number }>
): boolean {
return registrosReais.some(
(r) => r.tipo === registroEsperado.tipo
);
}
function abrirModalImpressao(funcionarioId: Id<'funcionarios'>) {
funcionarioParaImprimir = funcionarioId;
mostrarModalImpressao = true;
@@ -840,12 +889,19 @@
}
}
// Variável para armazenar saldos diários (usada no resumo do banco de horas)
const saldosDiariosPorData: Record<string, number> = {};
// Tabela de registros
if (sections.registrosPonto) {
const config = await client.query(api.configuracaoPonto.obterConfiguracao, {});
const tableData: string[][] = [];
if (!config) {
throw new Error('Configuração de ponto não encontrada');
}
// Agrupar por data para incluir saldo diário
const tableData: any[][] = [];
// Agrupar registros reais por data
const registrosPorData: Record<
string,
Array<{
@@ -885,24 +941,152 @@
});
}
// 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);
// Gerar todos os dias do período
const diasPeriodo = gerarDiasPeriodo(dataInicio, dataFim);
// Ordenar registros por hora e minuto para garantir ordem correta
const regsOrdenados = [...regs].sort((a, b) => {
// Processar cada dia do período
for (const data of diasPeriodo) {
const dataFormatada = formatarDataDDMMAAAA(data);
const regsReais = registrosPorData[data] || [];
const regsEsperados = gerarRegistrosEsperados(data, config);
// Combinar registros reais e esperados
const todosRegistros: Array<{
tipo: string;
hora: number;
minuto: number;
real: boolean;
dentroDoPrazo?: boolean;
}> = [];
// Adicionar registros reais
for (const reg of regsReais) {
todosRegistros.push({
tipo: reg.tipo,
hora: reg.hora,
minuto: reg.minuto,
real: true,
dentroDoPrazo: reg.dentroDoPrazo,
});
}
// Adicionar registros esperados não marcados (em vermelho)
for (const regEsperado of regsEsperados) {
if (!registroFoiMarcado(regEsperado, regsReais)) {
todosRegistros.push({
tipo: regEsperado.tipo,
hora: regEsperado.hora,
minuto: regEsperado.minuto,
real: false,
});
}
}
// Ordenar todos os registros por hora e minuto
todosRegistros.sort((a, b) => {
if (a.hora !== b.hora) {
return a.hora - b.hora;
}
return a.minuto - b.minuto;
});
// Calcular saldos por par entrada/saída
const saldosPorPar = calcularSaldosPorPar(regsOrdenados);
// Calcular saldos 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);
for (let i = 0; i < regsOrdenados.length; i++) {
const reg = regsOrdenados[i];
// 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();
// Identificar pares incompletos e calcular saldos esperados
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 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;
}
const horas = Math.floor(saldoMinutos / 60);
const minutos = saldoMinutos % 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
);
// Contar registros do par (entrada + saída esperada)
let tamanhoPar = 1; // entrada
const saidaEsperadaNaLista = todosRegistros.find(
(r, idx) => idx > indexNaListaTodos && r.tipo === tipoSaidaEsperado && !r.real
);
if (saidaEsperadaNaLista) {
tamanhoPar++; // saída faltante já está na lista
}
if (indexNaListaTodos >= 0) {
saldosEsperadosPorPar.set(indexNaListaTodos, {
saldoMinutos,
horas,
minutos,
tamanhoPar,
incompleto: true
});
}
}
}
}
}
// Calcular saldo diário total (soma de todos os pares reais)
let saldoDiarioTotalMinutos = 0;
for (const saldo of saldosPorPar.values()) {
saldoDiarioTotalMinutos += saldo.saldoMinutos;
}
// Se não há registros reais, calcular saldo esperado baseado na configuração
if (regsReais.length === 0) {
// Calcular saldo esperado do dia completo
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 minutosPar1Entrada = horaEntrada * 60 + minutoEntrada;
const minutosPar1Saida = horaSaidaAlmoco * 60 + minutoSaidaAlmoco;
let saldoPar1 = minutosPar1Saida - minutosPar1Entrada;
if (saldoPar1 < 0) saldoPar1 += 24 * 60;
// Par 2: retorno_almoco -> saida
const minutosPar2Entrada = horaRetornoAlmoco * 60 + minutoRetornoAlmoco;
const minutosPar2Saida = horaSaida * 60 + minutoSaida;
let saldoPar2 = minutosPar2Saida - minutosPar2Entrada;
if (saldoPar2 < 0) saldoPar2 += 24 * 60;
saldoDiarioTotalMinutos = saldoPar1 + saldoPar2;
}
saldosDiariosPorData[data] = saldoDiarioTotalMinutos;
// Criar linhas da tabela
for (let i = 0; i < todosRegistros.length; i++) {
const reg = todosRegistros[i];
const linha: any[] = [
dataFormatada,
config
@@ -916,28 +1100,110 @@
formatarHoraPonto(reg.hora, reg.minuto),
];
// Marcar linha como não marcada para aplicar cor vermelha depois
if (!reg.real) {
linha._naoMarcado = true;
}
// Saldo Diário por par entrada/saída
if (sections.saldoDiario) {
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 (reg.real && isInicioPar) {
const indexReal = regsReaisOrdenados.findIndex(
(r) => r.tipo === reg.tipo && r.hora === reg.hora && r.minuto === reg.minuto
);
// Verificar se há saldo esperado (par incompleto)
const saldoEsperado = saldosEsperadosPorPar.get(i);
if (saldoEsperado) {
// Par incompleto: mostrar saldo esperado em vermelho
linha._saldoVermelho = true;
linha.push({
content: `+${saldoEsperado.horas}h ${saldoEsperado.minutos}min`,
rowSpan: saldoEsperado.tamanhoPar
});
} else if (indexReal >= 0) {
const saldoPar = saldosPorPar.get(indexReal);
if (saldoPar) {
linha.push({
content: `+${saldoPar.horas}h ${saldoPar.minutos}min`,
rowSpan: saldoPar.tamanhoPar
});
} else {
linha.push('-');
}
} else {
linha.push('-');
}
} else if (reg.real) {
// Saída real: não adicionar (já coberto pelo rowspan da entrada)
// Mas verificar se precisa adicionar '-' se não houver par completo
const tipoEntradaEsperado = reg.tipo === 'saida_almoco' ? 'entrada' : 'retorno_almoco';
const entradaReal = regsReais.find(r => r.tipo === tipoEntradaEsperado);
if (!entradaReal) {
linha.push('-');
}
} else {
// Registro não marcado: verificar se faz parte de um par incompleto ou dia sem registros
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
});
// Verificar se há saldo esperado para este par
const saldoEsperado = saldosEsperadosPorPar.get(i);
if (saldoEsperado) {
// Par incompleto: mostrar saldo esperado em vermelho
linha._saldoVermelho = true;
linha.push({
content: `+${saldoEsperado.horas}h ${saldoEsperado.minutos}min`,
rowSpan: saldoEsperado.tamanhoPar
});
} else if (regsReais.length === 0) {
// Dia sem registros: calcular saldo esperado completo
if (reg.tipo === 'entrada') {
// Par 1 completo esperado
const [horaEntrada, minutoEntrada] = config.horarioEntrada.split(':').map(Number);
const [horaSaidaAlmoco, minutoSaidaAlmoco] = config.horarioSaidaAlmoco.split(':').map(Number);
const minutosEntrada = horaEntrada * 60 + minutoEntrada;
const minutosSaida = horaSaidaAlmoco * 60 + minutoSaidaAlmoco;
let saldoMinutos = minutosSaida - minutosEntrada;
if (saldoMinutos < 0) saldoMinutos += 24 * 60;
const horas = Math.floor(saldoMinutos / 60);
const minutos = saldoMinutos % 60;
linha._saldoVermelho = true;
linha.push({
content: `+${horas}h ${minutos}min`,
rowSpan: 2 // entrada + saida_almoco
});
} else if (reg.tipo === 'retorno_almoco') {
// Par 2 completo esperado
const [horaRetornoAlmoco, minutoRetornoAlmoco] = config.horarioRetornoAlmoco.split(':').map(Number);
const [horaSaida, minutoSaida] = config.horarioSaida.split(':').map(Number);
const minutosEntrada = horaRetornoAlmoco * 60 + minutoRetornoAlmoco;
const minutosSaida = horaSaida * 60 + minutoSaida;
let saldoMinutos = minutosSaida - minutosEntrada;
if (saldoMinutos < 0) saldoMinutos += 24 * 60;
const horas = Math.floor(saldoMinutos / 60);
const minutos = saldoMinutos % 60;
linha._saldoVermelho = true;
linha.push({
content: `+${horas}h ${minutos}min`,
rowSpan: 2 // retorno_almoco + saida
});
} else {
linha.push('-');
}
} else {
linha.push('-');
}
} else {
// Saída sem entrada correspondente: não tem saldo
linha.push('-');
}
// 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('-');
}
}
linha.push(reg.dentroDoPrazo ? 'Sim' : 'Não');
linha.push(reg.real ? (reg.dentroDoPrazo ? 'Sim' : 'Não') : 'Não marcado');
tableData.push(linha);
}
@@ -959,6 +1225,23 @@
theme: 'grid',
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
styles: { fontSize: 9 },
didParseCell: function(data) {
// Aplicar cor vermelha para registros não marcados
if (data.row.raw && (data.row.raw as any)._naoMarcado) {
// Aplicar cor vermelha nas colunas Tipo e Horário
if (data.column.index === 1 || data.column.index === 2) {
data.cell.styles.textColor = [200, 0, 0];
}
}
// Aplicar cor vermelha na coluna de saldo diário quando marcado
if (data.row.raw && (data.row.raw as any)._saldoVermelho) {
// Coluna de saldo diário (índice 3 se saldoDiario estiver ativo)
const indiceSaldoDiario = sections.saldoDiario ? 3 : -1;
if (data.column.index === indiceSaldoDiario) {
data.cell.styles.textColor = [200, 0, 0];
}
}
}
});
// Calcular posição Y após a tabela
@@ -986,27 +1269,34 @@
funcionarioId,
});
// Calcular saldo do período selecionado
// Calcular saldo do período selecionado baseado nos saldos diários calculados
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] = [];
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;
}
registrosPorDataPeriodo[dataKey]!.push({
tipo: r.tipo,
hora: r.hora,
minuto: r.minuto,
});
}
} else {
// Fallback: calcular a partir dos registros se não tiver saldos diários
const registrosPorDataPeriodo: Record<string, Array<{ tipo: string; hora: number; minuto: number }>> = {};
// 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;
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,
});
}
for (const regs of Object.values(registrosPorDataPeriodo)) {
const saldoDiario = calcularSaldoDiario(regs);
if (saldoDiario) {
saldoPeriodoMinutos += saldoDiario.saldoMinutos;
}
}
}
@@ -1015,6 +1305,10 @@
const sinalPeriodo = saldoPeriodoMinutos >= 0 ? '+' : '-';
const saldoPeriodoFormatado = `${sinalPeriodo}${horasPeriodo}h ${minutosPeriodo}min`;
// Calcular total de dias do período selecionado
const diasPeriodo = gerarDiasPeriodo(dataInicio, dataFim);
const totalDiasPeriodo = diasPeriodo.length;
doc.setFontSize(12);
doc.setFont('helvetica', 'bold');
doc.setTextColor(41, 128, 185);
@@ -1044,7 +1338,7 @@
bancoHorasData.push(['Horas a Pagar', `${horas}h ${minutos}min`]);
}
bancoHorasData.push(['Total de Dias com Registro', `${bancoHoras.totalDias} dias`]);
bancoHorasData.push(['Total de Dias do Período', `${totalDiasPeriodo} dias`]);
// Criar tabela no mesmo estilo das outras seções
autoTable(doc, {