diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/registro-pontos/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/registro-pontos/+page.svelte index ee2ce3b..962e565 100644 --- a/apps/web/src/routes/(dashboard)/recursos-humanos/registro-pontos/+page.svelte +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/registro-pontos/+page.svelte @@ -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 { + const saldos = new Map(); + + 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 = {}; + const saldosDiariosPorData: Record = {}; // 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 = new Map(); + const saldosEsperadosPorPar: Map = 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(); + const paresCompletosProcessados = new Set(); // 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); + + // 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 + } + } if (!saidaEncontrada) { - // Par incompleto: calcular saldo esperado baseado na configuração + // 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) { - const minutosEntrada = reg.hora * 60 + reg.minuto; - const minutosSaidaEsperada = regEsperado.hora * 60 + regEsperado.minuto; - let saldoMinutos = minutosSaidaEsperada - minutosEntrada; - if (saldoMinutos < 0) { - saldoMinutos += 24 * 60; + // 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; } - const horas = Math.floor(saldoMinutos / 60); - const minutos = saldoMinutos % 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; - linha.push({ - content: `+${saldoEsperado.horas}h ${saldoEsperado.minutos}min`, - rowSpan: saldoEsperado.tamanhoPar - }); + 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: `+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,19 +1582,53 @@ 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 { - linha.push('-'); + // 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 - linha.push('-'); + // 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'; } - } - // 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) { + + // 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'; + } + } 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]; + 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'; } }