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 687ed2a..ee2ce3b 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,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 = {}; + // 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) + // Gerar todos os dias do período + const diasPeriodo = gerarDiasPeriodo(dataInicio, dataFim); + + // Processar cada dia do período + for (const data of diasPeriodo) { const dataFormatada = formatarDataDDMMAAAA(data); - - // Ordenar registros por hora e minuto para garantir ordem correta - const regsOrdenados = [...regs].sort((a, b) => { + 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 (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); + + // Calcular saldos esperados para pares incompletos ou dias sem registros + const saldosEsperadosPorPar: Map = new Map(); - // Calcular saldos por par entrada/saída - const saldosPorPar = calcularSaldosPorPar(regsOrdenados); + // 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; + } - for (let i = 0; i < regsOrdenados.length; i++) { - const reg = regsOrdenados[i]; + // 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> = {}; - - 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; + } + } else { + // Fallback: calcular a partir dos registros se não tiver saldos diários + const registrosPorDataPeriodo: Record> = {}; + + 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, + }); } - 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; + 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, {