From db61df1fb437c7eb85bedba27b61b1d2a830983a Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Wed, 19 Nov 2025 16:37:31 -0300 Subject: [PATCH] feat: add new features for point management and registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Introduced "Homologação de Registro" and "Dispensa de Registro" sections in the dashboard for enhanced point management. - Updated the WidgetGestaoPontos component to include new links and icons for the added features. - Enhanced backend functionality to support the new features, including querying and managing dispensas and homologações. - Improved the PDF generation process to include daily balance calculations for employee time records. - Implemented checks for active dispensas to prevent unauthorized point registrations. --- .../components/ponto/PrintPontoModal.svelte | 200 +++++ .../ponto/WidgetGestaoPontos.svelte | 82 +- .../(dashboard)/recursos-humanos/+page.svelte | 13 + .../controle-ponto/+page.svelte | 118 +++ .../controle-ponto/dispensa/+page.svelte | 360 +++++++++ .../controle-ponto/homologacao/+page.svelte | 538 +++++++++++++ .../registro-pontos/+page.svelte | 721 ++++++++++++++---- packages/backend/convex/pontos.ts | 674 ++++++++++++++++ packages/backend/convex/schema.ts | 60 ++ 9 files changed, 2602 insertions(+), 164 deletions(-) create mode 100644 apps/web/src/lib/components/ponto/PrintPontoModal.svelte create mode 100644 apps/web/src/routes/(dashboard)/recursos-humanos/controle-ponto/+page.svelte create mode 100644 apps/web/src/routes/(dashboard)/recursos-humanos/controle-ponto/dispensa/+page.svelte create mode 100644 apps/web/src/routes/(dashboard)/recursos-humanos/controle-ponto/homologacao/+page.svelte diff --git a/apps/web/src/lib/components/ponto/PrintPontoModal.svelte b/apps/web/src/lib/components/ponto/PrintPontoModal.svelte new file mode 100644 index 0000000..1c2cb45 --- /dev/null +++ b/apps/web/src/lib/components/ponto/PrintPontoModal.svelte @@ -0,0 +1,200 @@ + + + + + + diff --git a/apps/web/src/lib/components/ponto/WidgetGestaoPontos.svelte b/apps/web/src/lib/components/ponto/WidgetGestaoPontos.svelte index ce48be3..1bc3c14 100644 --- a/apps/web/src/lib/components/ponto/WidgetGestaoPontos.svelte +++ b/apps/web/src/lib/components/ponto/WidgetGestaoPontos.svelte @@ -1,5 +1,5 @@ @@ -62,6 +62,86 @@

+ +
+
+
+
+ +
+
+ + + +
+

+ Homologação de Registro +

+

+ Edite registros de ponto e ajuste banco de horas +

+
+
+ +
+
+
+
+ +
+
+ + + +
+

+ Dispensa de Registro +

+

+ Gerencie períodos de dispensa de registro de ponto +

+
+
diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/+page.svelte index c468d3b..60992a0 100644 --- a/apps/web/src/routes/(dashboard)/recursos-humanos/+page.svelte +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/+page.svelte @@ -18,6 +18,7 @@ Info, ArrowRight, Clock, + XCircle, } from "lucide-svelte"; import type { Component } from "svelte"; @@ -134,6 +135,18 @@ href: "/recursos-humanos/registro-pontos", Icon: Clock, }, + { + nome: "Homologação de Registro", + descricao: "Edite registros de ponto e ajuste banco de horas", + href: "/recursos-humanos/controle-ponto/homologacao", + Icon: CheckCircle2, + }, + { + nome: "Dispensa de Registro", + descricao: "Gerencie períodos de dispensa de registro de ponto", + href: "/recursos-humanos/controle-ponto/dispensa", + Icon: XCircle, + }, ], }, ]; diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/controle-ponto/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/controle-ponto/+page.svelte new file mode 100644 index 0000000..9105abc --- /dev/null +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/controle-ponto/+page.svelte @@ -0,0 +1,118 @@ + + +
+ +
+
+ +
+
+

Controle de Ponto

+

Gerencie registros, homologações e dispensas de ponto

+
+
+ + + +
+ + diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/controle-ponto/dispensa/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/controle-ponto/dispensa/+page.svelte new file mode 100644 index 0000000..165d1bd --- /dev/null +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/controle-ponto/dispensa/+page.svelte @@ -0,0 +1,360 @@ + + +
+ +
+
+
+ +
+
+

Dispensa de Registro

+

Gerencie períodos de dispensa de registro de ponto

+
+
+ {#if !modoCriacao} + + {/if} +
+ + + {#if modoCriacao} +
+
+

Criar Dispensa de Registro

+ + +
+ +
+ {#each funcionarios as funcionario} + + {/each} +
+
+ +
+
+ + +
+ +
+ +
+ + : + +
+
+ +
+ + +
+ +
+ +
+ + : + +
+
+ +
+ + +
+ +
+ +

+ Se marcado, o funcionário ficará permanentemente dispensado de registrar ponto +

+
+
+ +
+ + +
+
+
+ {/if} + + +
+
+

Dispensas Ativas

+ + {#if dispensas.length === 0} +
+ Nenhuma dispensa ativa encontrada +
+ {:else} +
+ + + + + + + + + + + + + {#each dispensas as dispensa} + + + + + + + + + {/each} + +
FuncionárioPeríodoMotivoStatusGestorAções
+ {dispensa.funcionario?.nome || '-'} + {#if dispensa.funcionario?.matricula} +
+ + Mat: {dispensa.funcionario.matricula} + + {/if} +
+
+
+ Início:{' '} + {formatarDataHora(dispensa.dataInicio, dispensa.horaInicio, dispensa.minutoInicio)} +
+
+ Fim:{' '} + {formatarDataHora(dispensa.dataFim, dispensa.horaFim, dispensa.minutoFim)} +
+
+
{dispensa.motivo} + {#if dispensa.isento} + Isento (sem expiração) + {:else if dispensa.expirada} + Expirada + {:else} + Ativa + {/if} + {dispensa.gestor?.nome || '-'} + +
+
+ {/if} +
+
+
+ diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/controle-ponto/homologacao/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/controle-ponto/homologacao/+page.svelte new file mode 100644 index 0000000..913bbc9 --- /dev/null +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/controle-ponto/homologacao/+page.svelte @@ -0,0 +1,538 @@ + + +
+ +
+
+
+ +
+
+

Homologação de Registro

+

Edite registros de ponto e ajuste banco de horas

+
+
+
+ + +
+
+

Selecionar Funcionário

+ +
+
+ + + {#if funcionarioSelecionado && !modoEdicao && !modoAjuste} +
+ +
+ {/if} + + + {#if modoEdicao && registroSelecionado} +
+
+

Editar Registro de Ponto

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ + +
+
+
+ {/if} + + + {#if modoAjuste} +
+
+

Ajustar Banco de Horas

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ + +
+
+
+ {/if} + + + {#if funcionarioSelecionado && !modoEdicao && !modoAjuste} +
+
+

Registros do Funcionário

+ + {#if registros.length === 0} +
+ Nenhum registro encontrado +
+ {:else} +
+ + + + + + + + + + + + {#each registros as registro} + + + + + + + + {/each} + +
DataTipoHorárioStatusAções
{registro.data} + {getTipoRegistroLabel(registro.tipo)} + {formatarHoraPonto(registro.hora, registro.minuto)} + + {registro.dentroDoPrazo ? 'Dentro do Prazo' : 'Fora do Prazo'} + + + +
+
+ {/if} +
+
+ {/if} + + + {#if !modoEdicao && !modoAjuste} +
+
+

+ Histórico de Homologações + {#if funcionarioSelecionado} + + - Funcionário selecionado + + {:else} + + - Todas as homologações do seu time + + {/if} +

+ + {#if homologacoes.length === 0} +
+ Nenhuma homologação encontrada +
+ {:else} +
+ + + + + {#if !funcionarioSelecionado} + + {/if} + + + + + + + + {#each homologacoes as homologacao} + + + {#if !funcionarioSelecionado} + + {/if} + + + + + + {/each} + +
DataFuncionárioTipoDetalhesMotivoObservações
+ {new Date(homologacao.criadoEm).toLocaleDateString('pt-BR')} + + {homologacao.funcionario?.nome || '-'} + {#if homologacao.funcionario?.matricula} +
+ + Mat: {homologacao.funcionario.matricula} + + {/if} +
+ {#if homologacao.registroId} + Edição de Registro + {:else if homologacao.tipoAjuste} + + Ajuste: {homologacao.tipoAjuste} + + {/if} + + {#if homologacao.horaAnterior !== undefined} +
+ + {formatarHoraPonto(homologacao.horaAnterior, homologacao.minutoAnterior || 0)} + + {' → '} + + {formatarHoraPonto(homologacao.horaNova || 0, homologacao.minutoNova || 0)} + +
+ {:else if homologacao.ajusteMinutos} +
+ {homologacao.periodoDias || 0}d {homologacao.periodoHoras || 0}h{' '} + {homologacao.periodoMinutos || 0}min +
+ {/if} +
+
+ {homologacao.motivoDescricao || homologacao.motivoTipo || '-'} +
+
+
+ {homologacao.observacoes || '-'} +
+
+
+ {/if} +
+
+ {/if} +
+ 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 e2df0d1..521766a 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 @@ -8,6 +8,8 @@ import jsPDF from 'jspdf'; import autoTable from 'jspdf-autotable'; import logoGovPE from '$lib/assets/logo_governo_PE.png'; + import PrintPontoModal from '$lib/components/ponto/PrintPontoModal.svelte'; + import { toast } from 'svelte-sonner'; const client = useConvexClient(); @@ -16,6 +18,8 @@ let dataFim = $state(new Date().toISOString().split('T')[0]!); let funcionarioIdFiltro = $state | ''>(''); let carregando = $state(false); + let mostrarModalImpressao = $state(false); + let funcionarioParaImprimir = $state | ''>(''); // Parâmetros reativos para queries const registrosParams = $derived({ @@ -46,23 +50,76 @@ { funcionario: { nome: string; matricula?: string; descricaoCargo?: string } | null; funcionarioId: Id<'funcionarios'>; - registros: typeof registros; + registrosPorData: Record< + string, + { + data: string; + registros: Array; + saldoDiario?: { saldoMinutos: number; horas: number; minutos: number; positivo: boolean }; + } + >; } > = {}; + // Usar Set para evitar registros duplicados + const registrosProcessados = new Set(); + for (const registro of registros) { + // Criar chave única para evitar duplicatas + const chaveUnica = `${registro._id}`; + if (registrosProcessados.has(chaveUnica)) { + continue; // Pular se já foi processado + } + registrosProcessados.add(chaveUnica); + const key = registro.funcionarioId; if (!agrupados[key]) { agrupados[key] = { funcionario: registro.funcionario, funcionarioId: registro.funcionarioId, - registros: [], + registrosPorData: {}, }; } - agrupados[key]!.registros.push(registro); + + const dataKey = registro.data; + if (!agrupados[key]!.registrosPorData[dataKey]) { + agrupados[key]!.registrosPorData[dataKey] = { + data: dataKey, + registros: [], + saldoDiario: undefined, + }; + } + + // Verificar se o registro já não está no array antes de adicionar + const jaExiste = agrupados[key]!.registrosPorData[dataKey]!.registros.some( + (r) => r._id === registro._id + ); + if (!jaExiste) { + agrupados[key]!.registrosPorData[dataKey]!.registros.push(registro); + } } - return Object.values(agrupados); + // Ordenar registros por data e hora dentro de cada grupo e calcular saldo diário + const resultado = Object.values(agrupados); + for (const grupo of resultado) { + for (const dataKey in grupo.registrosPorData) { + const grupoData = grupo.registrosPorData[dataKey]; + if (grupoData) { + // Ordenar por hora e minuto + grupoData.registros.sort((a, b) => { + if (a.hora !== b.hora) { + return a.hora - b.hora; + } + return a.minuto - b.minuto; + }); + + // Calcular saldo diário como diferença entre saída e entrada + grupoData.saldoDiario = calcularSaldoDiario(grupoData.registros); + } + } + } + + return resultado; }); // Query para banco de horas de cada funcionário @@ -78,16 +135,91 @@ return `${sinal}${horas}h ${mins}min`; } - async function imprimirFichaPonto(funcionarioId: Id<'funcionarios'>) { - const registrosFuncionario = registros.filter((r) => r.funcionarioId === funcionarioId); - if (registrosFuncionario.length === 0) { - alert('Nenhum registro encontrado para este funcionário no período selecionado'); + // Função para formatar saldo diário + function formatarSaldoDiario(saldo?: { saldoMinutos: number; horas: number; minutos: number; positivo: boolean }): string { + if (!saldo) return '-'; + const sinal = saldo.positivo ? '+' : '-'; + return `${sinal}${saldo.horas}h ${saldo.minutos}min`; + } + + // Função para calcular saldo diário como diferença entre saída e entrada + function calcularSaldoDiario(registros: Array<{ tipo: string; hora: number; minuto: number }>): { saldoMinutos: number; horas: number; minutos: number; positivo: boolean } | null { + if (registros.length === 0) return null; + + // 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; + }); + + // Buscar entrada (primeiro registro do tipo 'entrada') + const entrada = registrosOrdenados.find((r) => r.tipo === 'entrada'); + // Buscar saída (último registro do tipo 'saida') + const saida = registrosOrdenados.filter((r) => r.tipo === 'saida').pop(); + + if (!entrada || !saida) return null; + + // Calcular diferença em minutos + const minutosEntrada = entrada.hora * 60 + entrada.minuto; + const minutosSaida = saida.hora * 60 + saida.minuto; + + // Se a saída for no dia seguinte (após meia-noite), adicionar 24 horas + 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; + + return { + saldoMinutos, + horas, + minutos, + positivo: true, // Sempre positivo, pois é tempo trabalhado + }; + } + + function abrirModalImpressao(funcionarioId: Id<'funcionarios'>) { + funcionarioParaImprimir = funcionarioId; + mostrarModalImpressao = true; + } + + async function gerarPDFComSelecao(sections: { + dadosFuncionario: boolean; + registrosPonto: boolean; + saldoDiario: boolean; + bancoHoras: boolean; + alteracoesGestor: boolean; + dispensasRegistro: boolean; + }) { + if (!funcionarioParaImprimir) return; + + const funcionarioId = funcionarioParaImprimir; + + // Verificar se pelo menos uma seção foi selecionada + if (!Object.values(sections).some((v) => v)) { + toast.error('Selecione pelo menos uma seção para imprimir'); return; } const funcionario = funcionarios.find((f) => f._id === funcionarioId); if (!funcionario) { - alert('Funcionário não encontrado'); + toast.error('Funcionário não encontrado'); + return; + } + + // Buscar registros do funcionário no período selecionado + const registrosFuncionario = await client.query(api.pontos.listarRegistrosPeriodo, { + funcionarioId, + dataInicio, + dataFim, + }); + + if (!registrosFuncionario || registrosFuncionario.length === 0) { + toast.error('Nenhum registro encontrado para este funcionário no período selecionado'); return; } @@ -123,141 +255,368 @@ yPosition += 10; // Dados do Funcionário - doc.setFontSize(12); - doc.setTextColor(0, 0, 0); - doc.setFont('helvetica', 'bold'); - doc.text('DADOS DO FUNCIONÁRIO', 15, yPosition); - doc.setFont('helvetica', 'normal'); + if (sections.dadosFuncionario) { + doc.setFontSize(12); + doc.setTextColor(0, 0, 0); + doc.setFont('helvetica', 'bold'); + doc.text('DADOS DO FUNCIONÁRIO', 15, yPosition); + doc.setFont('helvetica', 'normal'); - yPosition += 8; - doc.setFontSize(10); + yPosition += 8; + doc.setFontSize(10); - if (funcionario.matricula) { - doc.text(`Matrícula: ${funcionario.matricula}`, 15, yPosition); - yPosition += 6; - } - doc.text(`Nome: ${funcionario.nome}`, 15, yPosition); - yPosition += 6; - if (funcionario.descricaoCargo) { - doc.text(`Cargo/Função: ${funcionario.descricaoCargo}`, 15, yPosition); + if (funcionario.matricula) { + doc.text(`Matrícula: ${funcionario.matricula}`, 15, yPosition); + yPosition += 6; + } + doc.text(`Nome: ${funcionario.nome}`, 15, yPosition); yPosition += 6; + if (funcionario.descricaoCargo) { + doc.text(`Cargo/Função: ${funcionario.descricaoCargo}`, 15, yPosition); + yPosition += 6; + } + + yPosition += 5; + // Formatar período para exibição + const dataInicioParts = dataInicio.split('-'); + const dataFimParts = dataFim.split('-'); + const periodoFormatado = `${dataInicioParts[2]}/${dataInicioParts[1]}/${dataInicioParts[0]} a ${dataFimParts[2]}/${dataFimParts[1]}/${dataFimParts[0]}`; + doc.text(`Período: ${periodoFormatado}`, 15, yPosition); + yPosition += 10; } - yPosition += 5; - doc.text(`Período: ${dataInicio} a ${dataFim}`, 15, yPosition); - yPosition += 10; + // Buscar homologações e dispensas + let homologacoes: Array<{ + _id: Id<'homologacoesPonto'>; + criadoEm: number; + registroId?: Id<'registrosPonto'>; + horaAnterior?: number; + minutoAnterior?: number; + horaNova?: number; + minutoNova?: number; + tipoAjuste?: 'compensar' | 'abonar' | 'descontar'; + periodoDias?: number; + periodoHoras?: number; + periodoMinutos?: number; + motivoDescricao?: string; + motivoTipo?: string; + observacoes?: string; + }> = []; + + let dispensas: Array<{ + dataInicio: string; + dataFim: string; + horaInicio: number; + minutoInicio: number; + horaFim: number; + minutoFim: number; + motivo: string; + isento: boolean; + }> = []; + + if (sections.alteracoesGestor) { + try { + homologacoes = await client.query(api.pontos.listarHomologacoes, { + funcionarioId, + }) || []; + } catch (error) { + console.warn('Erro ao buscar homologações:', error); + // Continuar mesmo se houver erro ao buscar homologações + } + } + + if (sections.dispensasRegistro) { + try { + dispensas = await client.query(api.pontos.listarDispensas, { + funcionarioId, + apenasAtivas: false, + }) || []; + } catch (error) { + console.warn('Erro ao buscar dispensas:', error); + // Continuar mesmo se houver erro ao buscar dispensas + } + } // Tabela de registros - const config = await client.query(api.configuracaoPonto.obterConfiguracao, {}); - const tableData = registrosFuncionario.map((r) => [ - r.data, - config - ? getTipoRegistroLabel(r.tipo, { - nomeEntrada: config.nomeEntrada, - nomeSaidaAlmoco: config.nomeSaidaAlmoco, - nomeRetornoAlmoco: config.nomeRetornoAlmoco, - nomeSaida: config.nomeSaida, - }) - : getTipoRegistroLabel(r.tipo), - formatarHoraPonto(r.hora, r.minuto), - r.dentroDoPrazo ? 'Sim' : 'Não', - ]); + if (sections.registrosPonto) { + const config = await client.query(api.configuracaoPonto.obterConfiguracao, {}); + const tableData: string[][] = []; - // Salvar a posição Y antes da tabela - const yPosAntesTabela = yPosition; - - autoTable(doc, { - startY: yPosition, - head: [['Data', 'Tipo', 'Horário', 'Dentro do Prazo']], - body: tableData, - theme: 'grid', - headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' }, - styles: { fontSize: 9 }, - }); + // Agrupar por data para incluir saldo diário + const registrosPorData: Record< + string, + Array<{ + data: string; + tipo: string; + hora: number; + minuto: number; + dentroDoPrazo: boolean; + }> + > = {}; - // Obter banco de horas do funcionário - const bancoHoras = await client.query(api.pontos.obterBancoHorasFuncionario, { - funcionarioId, - }); - - // Calcular posição Y após a tabela - // autoTable armazena a posição final em doc.lastAutoTable.finalY - const lastPage = doc.getNumberOfPages(); - doc.setPage(lastPage); - const finalY = (doc as any).lastAutoTable?.finalY; - - // Se não conseguir obter a posição final, estimar baseado no número de linhas - if (finalY) { - yPosition = finalY; - } else { - // Estimativa: cada linha da tabela ocupa aproximadamente 7mm - const linhasTabela = tableData.length + 1; // +1 para o cabeçalho - yPosition = yPosAntesTabela + (linhasTabela * 7) + 10; - } - - // Adicionar espaço antes do resumo - yPosition += 10; - - // Verificar se precisa de nova página - if (yPosition > doc.internal.pageSize.getHeight() - 60) { - doc.addPage(); - yPosition = 20; - } - - // Resumo do Banco de Horas - 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; - const horas = Math.floor(Math.abs(saldoMinutos) / 60); - const minutos = Math.abs(saldoMinutos) % 60; - const sinal = saldoMinutos >= 0 ? '+' : '-'; - const saldoFormatado = `${sinal}${horas}h ${minutos}min`; - - // Saldo Atual - doc.setFont('helvetica', 'bold'); - doc.text('Saldo Atual:', 15, yPosition); - doc.setFont('helvetica', 'normal'); - doc.text(saldoFormatado, 60, yPosition); - yPosition += 8; - - // Horas Excedentes (se positivo) - if (saldoMinutos > 0) { - doc.setFont('helvetica', 'bold'); - doc.setTextColor(0, 128, 0); // Verde - 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; + for (const r of registrosFuncionario) { + const dataKey = r.data; + if (!registrosPorData[dataKey]) { + registrosPorData[dataKey] = []; + } + registrosPorData[dataKey]!.push({ + data: r.data, + tipo: r.tipo, + hora: r.hora, + minuto: r.minuto, + dentroDoPrazo: r.dentroDoPrazo, + }); } - // Horas a Pagar (se negativo) - if (saldoMinutos < 0) { - doc.setFont('helvetica', 'bold'); - doc.setTextColor(200, 0, 0); // Vermelho - 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; + // Criar dados da tabela com saldo diário + for (const [data, regs] of Object.entries(registrosPorData)) { + // Formatar data para exibição (DD/MM/YYYY) + const dataParts = data.split('-'); + const dataFormatada = `${dataParts[2]}/${dataParts[1]}/${dataParts[0]}`; + + // Calcular saldo diário como diferença entre saída e entrada + const saldoDiarioDia = calcularSaldoDiario(regs); + + for (const reg of regs) { + const linha: string[] = [ + dataFormatada, + config + ? getTipoRegistroLabel(reg.tipo as 'entrada' | 'saida_almoco' | 'retorno_almoco' | 'saida', { + nomeEntrada: config.nomeEntrada, + nomeSaidaAlmoco: config.nomeSaidaAlmoco, + nomeRetornoAlmoco: config.nomeRetornoAlmoco, + nomeSaida: config.nomeSaida, + }) + : getTipoRegistroLabel(reg.tipo as 'entrada' | 'saida_almoco' | 'retorno_almoco' | 'saida'), + formatarHoraPonto(reg.hora, reg.minuto), + ]; + + // Saldo Diário sempre após Horário + if (sections.saldoDiario) { + if (saldoDiarioDia) { + const sinal = saldoDiarioDia.positivo ? '+' : '-'; + linha.push(`${sinal}${saldoDiarioDia.horas}h ${saldoDiarioDia.minutos}min`); + } else { + linha.push('-'); + } + } + + linha.push(reg.dentroDoPrazo ? 'Sim' : 'Não'); + + tableData.push(linha); + } } - // Total de dias registrados + const headers = ['Data', 'Tipo', 'Horário']; + if (sections.saldoDiario) { + headers.push('Saldo Diário'); + } + headers.push('Dentro do Prazo'); + + // Salvar a posição Y antes da tabela + const yPosAntesTabela = yPosition; + + autoTable(doc, { + startY: yPosition, + head: [headers], + body: tableData, + theme: 'grid', + headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' }, + styles: { fontSize: 9 }, + }); + + // 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; + } else { + const linhasTabela = tableData.length + 1; + yPosition = yPosAntesTabela + linhasTabela * 7 + 10; + } + yPosition += 10; + } + + // Banco de Horas + if (sections.bancoHoras) { + if (yPosition > doc.internal.pageSize.getHeight() - 60) { + doc.addPage(); + yPosition = 20; + } + + const bancoHoras = await client.query(api.pontos.obterBancoHorasFuncionario, { + funcionarioId, + }); + + doc.setFontSize(12); doc.setFont('helvetica', 'bold'); - doc.text('Total de Dias com Registro:', 15, yPosition); + doc.setTextColor(41, 128, 185); + doc.text('RESUMO DO BANCO DE HORAS', 15, yPosition); doc.setFont('helvetica', 'normal'); - doc.text(`${bancoHoras.totalDias} dias`, 95, yPosition); - } else { - doc.text('Banco de horas não disponível', 15, yPosition); + doc.setTextColor(0, 0, 0); + + yPosition += 10; + doc.setFontSize(10); + + if (bancoHoras) { + const saldoMinutos = bancoHoras.saldoAcumuladoMinutos; + const horas = Math.floor(Math.abs(saldoMinutos) / 60); + const minutos = Math.abs(saldoMinutos) % 60; + 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; + + 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; + } + + 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; + } + + 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; + } else { + doc.text('Banco de horas não disponível', 15, yPosition); + yPosition += 10; + } + } + + // Alterações pelo Gestor + if (sections.alteracoesGestor && homologacoes.length > 0) { + if (yPosition > doc.internal.pageSize.getHeight() - 60) { + doc.addPage(); + yPosition = 20; + } + + doc.setFontSize(12); + doc.setFont('helvetica', 'bold'); + doc.setTextColor(41, 128, 185); + doc.text('ALTERAÇÕES PELO GESTOR', 15, yPosition); + doc.setFont('helvetica', 'normal'); + doc.setTextColor(0, 0, 0); + yPosition += 10; + + const homologacoesData = homologacoes.map((h) => { + // Formatar data de criação + const dataCriacao = new Date(h.criadoEm); + const dataFormatada = `${dataCriacao.getDate().toString().padStart(2, '0')}/${(dataCriacao.getMonth() + 1).toString().padStart(2, '0')}/${dataCriacao.getFullYear()}`; + + if (h.registroId && h.horaAnterior !== undefined) { + return [ + dataFormatada, + 'Edição de Registro', + h.horaAnterior !== undefined + ? `${formatarHoraPonto(h.horaAnterior, h.minutoAnterior || 0)} → ${formatarHoraPonto(h.horaNova || 0, h.minutoNova || 0)}` + : '-', + h.motivoDescricao || h.motivoTipo || '-', + h.observacoes || '-', + ]; + } else if (h.tipoAjuste) { + const tipoAjusteLabel = h.tipoAjuste === 'compensar' ? 'Compensar' : h.tipoAjuste === 'abonar' ? 'Abonar' : 'Descontar'; + return [ + dataFormatada, + `Ajuste: ${tipoAjusteLabel}`, + `${h.periodoDias || 0}d ${h.periodoHoras || 0}h ${h.periodoMinutos || 0}min`, + h.motivoDescricao || h.motivoTipo || '-', + h.observacoes || '-', + ]; + } + return []; + }).filter((row) => row.length > 0); + + if (homologacoesData.length > 0) { + autoTable(doc, { + startY: yPosition, + head: [['Data', 'Tipo', 'Detalhes', 'Motivo', 'Observações']], + body: homologacoesData, + 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 += homologacoesData.length * 7 + 10; + } + } + } + + // Dispensas de Registro + if (sections.dispensasRegistro && dispensas.length > 0) { + if (yPosition > doc.internal.pageSize.getHeight() - 60) { + doc.addPage(); + yPosition = 20; + } + + doc.setFontSize(12); + doc.setFont('helvetica', 'bold'); + doc.setTextColor(41, 128, 185); + doc.text('DISPENSAS DE REGISTRO', 15, yPosition); + doc.setFont('helvetica', 'normal'); + doc.setTextColor(0, 0, 0); + yPosition += 10; + + const dispensasData = dispensas.map((d) => { + // Formatar data de início + const dataInicioParts = d.dataInicio.split('-'); + const dataInicioFormatada = `${dataInicioParts[2]}/${dataInicioParts[1]}/${dataInicioParts[0]}`; + + // Formatar data de fim + const dataFimParts = d.dataFim.split('-'); + const dataFimFormatada = `${dataFimParts[2]}/${dataFimParts[1]}/${dataFimParts[0]}`; + + return [ + `${dataInicioFormatada} ${d.horaInicio.toString().padStart(2, '0')}:${d.minutoInicio.toString().padStart(2, '0')}`, + `${dataFimFormatada} ${d.horaFim.toString().padStart(2, '0')}:${d.minutoFim.toString().padStart(2, '0')}`, + d.motivo, + d.isento ? 'Isento (sem expiração)' : 'Temporária', + ]; + }); + + autoTable(doc, { + startY: yPosition, + head: [['Início', 'Fim', 'Motivo', 'Tipo']], + body: dispensasData, + 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 += dispensasData.length * 7 + 10; + } } // Rodapé @@ -277,9 +636,15 @@ // Salvar const nomeArquivo = `ficha-ponto-${funcionario.matricula || funcionario.nome}-${dataInicio}-${dataFim}.pdf`; doc.save(nomeArquivo); + + // Fechar modal após gerar PDF + mostrarModalImpressao = false; + funcionarioParaImprimir = ''; + toast.success('PDF gerado com sucesso!'); } catch (error) { console.error('Erro ao gerar PDF:', error); - alert('Erro ao gerar ficha de ponto. Tente novamente.'); + const errorMessage = error instanceof Error ? error.message : String(error); + toast.error(`Erro ao gerar ficha de ponto: ${errorMessage}`); } } @@ -869,7 +1234,7 @@ - - + {#each Object.values(grupo.registrosPorData) as grupoData} + {@const totalRegistros = grupoData.registros.length} + {@const dataParts = grupoData.data.split('-')} + {@const dataFormatada = `${dataParts[2]}/${dataParts[1]}/${dataParts[0]}`} + {#each grupoData.registros as registro, index} + + {dataFormatada} + + {config + ? getTipoRegistroLabel(registro.tipo, { + nomeEntrada: config.nomeEntrada, + nomeSaidaAlmoco: config.nomeSaidaAlmoco, + nomeRetornoAlmoco: config.nomeRetornoAlmoco, + nomeSaida: config.nomeSaida, + }) + : getTipoRegistroLabel(registro.tipo)} + + {formatarHoraPonto(registro.hora, registro.minuto)} + {#if index === 0} + + {#if grupoData.saldoDiario} + + {formatarSaldoDiario(grupoData.saldoDiario)} + + {:else} + - + {/if} + + {/if} + + + {registro.dentroDoPrazo ? 'Dentro do Prazo' : 'Fora do Prazo'} + + + + + + + {/each} {/each} @@ -933,3 +1317,14 @@ +{#if mostrarModalImpressao && funcionarioParaImprimir} + { + mostrarModalImpressao = false; + funcionarioParaImprimir = ''; + }} + onGenerate={gerarPDFComSelecao} + /> +{/if} + diff --git a/packages/backend/convex/pontos.ts b/packages/backend/convex/pontos.ts index 148ac73..6f8202c 100644 --- a/packages/backend/convex/pontos.ts +++ b/packages/backend/convex/pontos.ts @@ -173,6 +173,55 @@ export const registrarPonto = mutation({ throw new Error('Já existe um registro neste minuto'); } + // Verificar se funcionário está dispensado de registrar ponto + const dispensas = await ctx.db + .query('dispensasRegistro') + .withIndex('by_funcionario', (q) => q.eq('funcionarioId', funcionarioId)) + .filter((q) => q.eq(q.field('ativo'), true)) + .collect(); + + const dataConsulta = new Date(data); + for (const dispensa of dispensas) { + // Se for isento, sempre está dispensado + if (dispensa.isento) { + throw new Error('Registro dispensado pelo gestor: Isento de registro (caso excepcional)'); + } + + // Verificar se está no período + const dataInicio = new Date(dispensa.dataInicio); + const dataFim = new Date(dispensa.dataFim); + + if (dataConsulta >= dataInicio && dataConsulta <= dataFim) { + // Verificar hora e minuto se necessário + const timestampConsulta = new Date( + `${data}T${hora.toString().padStart(2, '0')}:${minuto.toString().padStart(2, '0')}:00` + ).getTime(); + const timestampInicio = new Date( + `${dispensa.dataInicio}T${dispensa.horaInicio.toString().padStart(2, '0')}:${dispensa.minutoInicio.toString().padStart(2, '0')}:00` + ).getTime(); + const timestampFim = new Date( + `${dispensa.dataFim}T${dispensa.horaFim.toString().padStart(2, '0')}:${dispensa.minutoFim.toString().padStart(2, '0')}:00` + ).getTime(); + + if (timestampConsulta >= timestampInicio && timestampConsulta <= timestampFim) { + throw new Error(`Registro dispensado pelo gestor: ${dispensa.motivo}`); + } + } + + // Verificar se expirou (desativar na mutation de registro) + const agora = new Date(); + const dataFimTimestamp = new Date( + `${dispensa.dataFim}T${dispensa.horaFim.toString().padStart(2, '0')}:${dispensa.minutoFim.toString().padStart(2, '0')}:00` + ).getTime(); + + if (agora.getTime() > dataFimTimestamp && !dispensa.isento) { + // Desativar dispensa expirada (mutation pode fazer isso) + await ctx.db.patch(dispensa._id, { + ativo: false, + }); + } + } + // Determinar tipo de registro const tipo = await determinarTipoRegistro(ctx, usuario.funcionarioId, data); @@ -274,6 +323,45 @@ export const listarRegistrosDia = query({ }, }); +/** + * Obtém saldo diário de um funcionário para uma data específica + */ +export const obterSaldoDiario = query({ + args: { + funcionarioId: v.id('funcionarios'), + data: v.string(), // YYYY-MM-DD + }, + handler: async (ctx, args) => { + // Buscar banco de horas do dia + const bancoHoras = await ctx.db + .query('bancoHoras') + .withIndex('by_funcionario_data', (q) => + q.eq('funcionarioId', args.funcionarioId).eq('data', args.data) + ) + .first(); + + if (!bancoHoras) { + return { + saldoMinutos: 0, + horas: 0, + minutos: 0, + positivo: true, + }; + } + + const horas = Math.floor(Math.abs(bancoHoras.saldoMinutos) / 60); + const minutos = Math.abs(bancoHoras.saldoMinutos) % 60; + const positivo = bancoHoras.saldoMinutos >= 0; + + return { + saldoMinutos: bancoHoras.saldoMinutos, + horas, + minutos, + positivo, + }; + }, +}); + /** * Lista registros por período (para RH) */ @@ -313,8 +401,32 @@ export const listarRegistrosPeriodo = query({ Array.from(funcionariosIds).map((id) => ctx.db.get(id)) ); + // Buscar saldos diários para cada data/funcionário + const saldosPorDataFuncionario: Record = {}; + const datasUnicas = new Set(registrosFiltrados.map((r) => `${r.funcionarioId}-${r.data}`)); + + for (const chave of datasUnicas) { + const [funcId, data] = chave.split('-'); + const bancoHoras = await ctx.db + .query('bancoHoras') + .withIndex('by_funcionario_data', (q) => + q.eq('funcionarioId', funcId as Id<'funcionarios'>).eq('data', data) + ) + .first(); + + if (bancoHoras) { + saldosPorDataFuncionario[chave] = bancoHoras.saldoMinutos; + } + } + return registrosFiltrados.map((registro) => { const funcionario = funcionarios.find((f) => f?._id === registro.funcionarioId); + const chave = `${registro.funcionarioId}-${registro.data}`; + const saldoMinutos = saldosPorDataFuncionario[chave] || 0; + const horas = Math.floor(Math.abs(saldoMinutos) / 60); + const minutos = Math.abs(saldoMinutos) % 60; + const positivo = saldoMinutos >= 0; + return { ...registro, funcionario: funcionario @@ -324,6 +436,12 @@ export const listarRegistrosPeriodo = query({ descricaoCargo: funcionario.descricaoCargo, } : null, + saldoDiario: { + saldoMinutos, + horas, + minutos, + positivo, + }, }; }); }, @@ -613,11 +731,20 @@ export const obterHistoricoESaldoDia = query({ const horasTrabalhadas = calcularHorasTrabalhadas(registros); const saldoMinutos = horasTrabalhadas - cargaHorariaDiaria; + const horas = Math.floor(Math.abs(saldoMinutos) / 60); + const minutos = Math.abs(saldoMinutos) % 60; + const positivo = saldoMinutos >= 0; + return { registros, cargaHorariaDiaria, horasTrabalhadas, saldoMinutos, + saldoFormatado: { + horas, + minutos, + positivo, + }, }; }, }); @@ -658,3 +785,550 @@ export const obterBancoHorasFuncionario = query({ }, }); +/** + * Helper: Verificar se usuário é gestor do funcionário + */ +async function verificarGestorDoFuncionario( + ctx: QueryCtx | MutationCtx, + gestorId: Id<'usuarios'>, + funcionarioId: Id<'funcionarios'> +): Promise { + const membroTime = await ctx.db + .query('timesMembros') + .withIndex('by_funcionario', (q) => q.eq('funcionarioId', funcionarioId)) + .filter((q) => q.eq(q.field('ativo'), true)) + .first(); + + if (!membroTime) return false; + + const time = await ctx.db.get(membroTime.timeId); + if (!time) return false; + + return time.gestorId === gestorId; +} + +/** + * Edita um registro de ponto (homologação pelo gestor) + */ +export const editarRegistroPonto = mutation({ + args: { + registroId: v.id('registrosPonto'), + horaNova: v.number(), + minutoNova: v.number(), + motivoId: v.optional(v.string()), + motivoTipo: v.optional(v.string()), + motivoDescricao: v.optional(v.string()), + observacoes: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) { + throw new Error('Usuário não autenticado'); + } + + // Buscar registro + const registro = await ctx.db.get(args.registroId); + if (!registro) { + throw new Error('Registro não encontrado'); + } + + // Verificar se é gestor do funcionário + const isGestor = await verificarGestorDoFuncionario(ctx, usuario._id, registro.funcionarioId); + if (!isGestor) { + throw new Error('Você não tem permissão para editar este registro'); + } + + // Salvar dados anteriores + const horaAnterior = registro.hora; + const minutoAnterior = registro.minuto; + + // Atualizar registro + await ctx.db.patch(args.registroId, { + hora: args.horaNova, + minuto: args.minutoNova, + editadoPorGestor: true, + }); + + // Criar registro de homologação + const homologacaoId = await ctx.db.insert('homologacoesPonto', { + registroId: args.registroId, + funcionarioId: registro.funcionarioId, + gestorId: usuario._id, + horaAnterior, + minutoAnterior, + horaNova: args.horaNova, + minutoNova: args.minutoNova, + motivoId: args.motivoId, + motivoTipo: args.motivoTipo, + motivoDescricao: args.motivoDescricao, + observacoes: args.observacoes, + criadoEm: Date.now(), + }); + + // Atualizar registro com ID da homologação + await ctx.db.patch(args.registroId, { + homologacaoId, + }); + + // Recalcular banco de horas do dia + const config = await ctx.db + .query('configuracaoPonto') + .withIndex('by_ativo', (q) => q.eq('ativo', true)) + .first(); + + if (config) { + await atualizarBancoHoras(ctx, registro.funcionarioId, registro.data, config); + } + + return { success: true, homologacaoId }; + }, +}); + +/** + * Ajusta banco de horas (compensar, abonar ou descontar) + */ +export const ajustarBancoHoras = mutation({ + args: { + funcionarioId: v.id('funcionarios'), + tipoAjuste: v.union(v.literal('compensar'), v.literal('abonar'), v.literal('descontar')), + periodoDias: v.number(), + periodoHoras: v.number(), + periodoMinutos: v.number(), + motivoId: v.optional(v.string()), + motivoTipo: v.optional(v.string()), + motivoDescricao: v.optional(v.string()), + observacoes: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) { + throw new Error('Usuário não autenticado'); + } + + // Verificar se é gestor do funcionário + const isGestor = await verificarGestorDoFuncionario(ctx, usuario._id, args.funcionarioId); + if (!isGestor) { + throw new Error('Você não tem permissão para ajustar banco de horas deste funcionário'); + } + + // Calcular ajuste em minutos + const ajusteMinutos = + args.periodoDias * 24 * 60 + args.periodoHoras * 60 + args.periodoMinutos; + + // Aplicar sinal baseado no tipo de ajuste + let ajusteFinal = ajusteMinutos; + if (args.tipoAjuste === 'descontar') { + ajusteFinal = -ajusteMinutos; + } + + // Buscar banco de horas mais recente ou criar um registro de ajuste + const hoje = new Date().toISOString().split('T')[0]!; + const bancoHorasAtual = await ctx.db + .query('bancoHoras') + .withIndex('by_funcionario_data', (q) => + q.eq('funcionarioId', args.funcionarioId).eq('data', hoje) + ) + .first(); + + if (bancoHorasAtual) { + // Atualizar saldo do dia atual + await ctx.db.patch(bancoHorasAtual._id, { + saldoMinutos: bancoHorasAtual.saldoMinutos + ajusteFinal, + }); + } else { + // Criar novo registro de banco de horas para o ajuste + const config = await ctx.db + .query('configuracaoPonto') + .withIndex('by_ativo', (q) => q.eq('ativo', true)) + .first(); + + if (!config) { + throw new Error('Configuração de ponto não encontrada'); + } + + const cargaHorariaDiaria = calcularCargaHorariaDiaria(config); + + await ctx.db.insert('bancoHoras', { + funcionarioId: args.funcionarioId, + data: hoje, + cargaHorariaDiaria, + horasTrabalhadas: 0, + saldoMinutos: ajusteFinal, + registrosPontoIds: [], + calculadoEm: Date.now(), + }); + } + + // Criar registro de homologação + const homologacaoId = await ctx.db.insert('homologacoesPonto', { + funcionarioId: args.funcionarioId, + gestorId: usuario._id, + motivoId: args.motivoId, + motivoTipo: args.motivoTipo, + motivoDescricao: args.motivoDescricao, + observacoes: args.observacoes, + tipoAjuste: args.tipoAjuste, + periodoDias: args.periodoDias, + periodoHoras: args.periodoHoras, + periodoMinutos: args.periodoMinutos, + ajusteMinutos: ajusteFinal, + criadoEm: Date.now(), + }); + + return { success: true, homologacaoId, ajusteMinutos: ajusteFinal }; + }, +}); + +/** + * Lista homologações de um funcionário ou time + */ +export const listarHomologacoes = query({ + args: { + funcionarioId: v.optional(v.id('funcionarios')), + }, + handler: async (ctx, args) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) { + throw new Error('Usuário não autenticado'); + } + + let homologacoes; + + if (args.funcionarioId) { + // Verificar se é gestor do funcionário ou o próprio funcionário + const funcionarioId = args.funcionarioId; // Garantir que não é undefined + const isGestor = await verificarGestorDoFuncionario(ctx, usuario._id, funcionarioId); + const isProprioFuncionario = usuario.funcionarioId === funcionarioId; + + if (!isGestor && !isProprioFuncionario) { + throw new Error('Você não tem permissão para ver estas homologações'); + } + + homologacoes = await ctx.db + .query('homologacoesPonto') + .withIndex('by_funcionario', (q) => q.eq('funcionarioId', funcionarioId)) + .order('desc') + .collect(); + } else { + // Listar homologações do gestor + homologacoes = await ctx.db + .query('homologacoesPonto') + .withIndex('by_gestor', (q) => q.eq('gestorId', usuario._id)) + .order('desc') + .collect(); + } + + // Buscar informações adicionais + const homologacoesComDetalhes = await Promise.all( + homologacoes.map(async (h) => { + const funcionario = await ctx.db.get(h.funcionarioId); + const gestor = await ctx.db.get(h.gestorId); + const registro = h.registroId ? await ctx.db.get(h.registroId) : null; + + return { + ...h, + funcionario: funcionario + ? { + nome: funcionario.nome, + matricula: funcionario.matricula, + } + : null, + gestor: gestor + ? { + nome: gestor.nome, + } + : null, + registro: registro + ? { + data: registro.data, + tipo: registro.tipo, + } + : null, + }; + }) + ); + + return homologacoesComDetalhes; + }, +}); + +/** + * Obtém opções de motivos de atestados/declarações + */ +export const obterMotivosAtestados = query({ + args: {}, + handler: async (ctx) => { + // Buscar tipos de atestados e declarações + const atestados = await ctx.db.query('atestados').collect(); + const tiposUnicos = new Set(); + + atestados.forEach((a) => { + if (a.cid) tiposUnicos.add(`CID: ${a.cid}`); + if (a.observacoes) tiposUnicos.add(a.observacoes); + }); + + return { + tipos: Array.from(tiposUnicos), + opcoesPadrao: [ + 'Atestado Médico', + 'Declaração', + 'Ajuste Administrativo', + 'Compensação de Horas', + 'Abono', + 'Desconto em Folha', + ], + }; + }, +}); + +/** + * Cria uma dispensa de registro de ponto + */ +export const criarDispensaRegistro = mutation({ + args: { + funcionarioId: v.id('funcionarios'), + dataInicio: v.string(), // YYYY-MM-DD + horaInicio: v.number(), + minutoInicio: v.number(), + dataFim: v.string(), // YYYY-MM-DD + horaFim: v.number(), + minutoFim: v.number(), + motivo: v.string(), + isento: v.boolean(), + }, + handler: async (ctx, args) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) { + throw new Error('Usuário não autenticado'); + } + + // Verificar se é gestor do funcionário + const isGestor = await verificarGestorDoFuncionario(ctx, usuario._id, args.funcionarioId); + if (!isGestor) { + throw new Error('Você não tem permissão para criar dispensa para este funcionário'); + } + + // Validar datas + const dataInicioObj = new Date(args.dataInicio); + const dataFimObj = new Date(args.dataFim); + + if (dataFimObj < dataInicioObj) { + throw new Error('Data fim deve ser maior ou igual à data início'); + } + + // Criar dispensa + const dispensaId = await ctx.db.insert('dispensasRegistro', { + funcionarioId: args.funcionarioId, + gestorId: usuario._id, + dataInicio: args.dataInicio, + horaInicio: args.horaInicio, + minutoInicio: args.minutoInicio, + dataFim: args.dataFim, + horaFim: args.horaFim, + minutoFim: args.minutoFim, + motivo: args.motivo, + isento: args.isento, + ativo: true, + criadoEm: Date.now(), + }); + + return { success: true, dispensaId }; + }, +}); + +/** + * Remove uma dispensa de registro (cancela) + */ +export const removerDispensaRegistro = mutation({ + args: { + dispensaId: v.id('dispensasRegistro'), + }, + handler: async (ctx, args) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) { + throw new Error('Usuário não autenticado'); + } + + const dispensa = await ctx.db.get(args.dispensaId); + if (!dispensa) { + throw new Error('Dispensa não encontrada'); + } + + // Verificar se é gestor do funcionário + const isGestor = await verificarGestorDoFuncionario(ctx, usuario._id, dispensa.funcionarioId); + if (!isGestor && dispensa.gestorId !== usuario._id) { + throw new Error('Você não tem permissão para remover esta dispensa'); + } + + // Desativar dispensa + await ctx.db.patch(args.dispensaId, { + ativo: false, + }); + + return { success: true }; + }, +}); + +/** + * Lista dispensas de registro + */ +export const listarDispensas = query({ + args: { + funcionarioId: v.optional(v.id('funcionarios')), + apenasAtivas: v.optional(v.boolean()), + }, + handler: async (ctx, args) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) { + throw new Error('Usuário não autenticado'); + } + + let dispensas; + + if (args.funcionarioId) { + // Verificar se é gestor do funcionário ou o próprio funcionário + const funcionarioId = args.funcionarioId; // Garantir que não é undefined + const isGestor = await verificarGestorDoFuncionario(ctx, usuario._id, funcionarioId); + const isProprioFuncionario = usuario.funcionarioId === funcionarioId; + + if (!isGestor && !isProprioFuncionario) { + throw new Error('Você não tem permissão para ver estas dispensas'); + } + + dispensas = await ctx.db + .query('dispensasRegistro') + .withIndex('by_funcionario', (q) => q.eq('funcionarioId', funcionarioId)) + .filter((q) => { + if (args.apenasAtivas !== undefined && args.apenasAtivas) { + return q.eq(q.field('ativo'), true); + } + return true; // Retornar todas se apenasAtivas não for especificado + }) + .order('desc') + .collect(); + } else { + // Listar dispensas do gestor + dispensas = await ctx.db + .query('dispensasRegistro') + .withIndex('by_gestor', (q) => q.eq('gestorId', usuario._id)) + .filter((q) => { + if (args.apenasAtivas !== undefined && args.apenasAtivas) { + return q.eq(q.field('ativo'), true); + } + return true; // Retornar todas se apenasAtivas não for especificado + }) + .order('desc') + .collect(); + } + + // Buscar informações adicionais + const dispensasComDetalhes = await Promise.all( + dispensas.map(async (d) => { + const funcionario = await ctx.db.get(d.funcionarioId); + const gestor = await ctx.db.get(d.gestorId); + + // Verificar se expirou (se não for isento) + let expirada = false; + if (!d.isento) { + const agora = new Date(); + const dataFimTimestamp = new Date( + `${d.dataFim}T${d.horaFim.toString().padStart(2, '0')}:${d.minutoFim.toString().padStart(2, '0')}:00` + ).getTime(); + expirada = agora.getTime() > dataFimTimestamp; + } + + return { + ...d, + funcionario: funcionario + ? { + nome: funcionario.nome, + matricula: funcionario.matricula, + } + : null, + gestor: gestor + ? { + nome: gestor.nome, + } + : null, + expirada, + }; + }) + ); + + return dispensasComDetalhes; + }, +}); + +/** + * Verifica se funcionário está dispensado de registrar ponto em uma data/hora específica + */ +export const verificarDispensaAtiva = query({ + args: { + funcionarioId: v.id('funcionarios'), + data: v.string(), // YYYY-MM-DD + hora: v.optional(v.number()), + minuto: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const dispensas = await ctx.db + .query('dispensasRegistro') + .withIndex('by_funcionario', (q) => q.eq('funcionarioId', args.funcionarioId)) + .filter((q) => q.eq(q.field('ativo'), true)) + .collect(); + + const dataConsulta = new Date(args.data); + + for (const dispensa of dispensas) { + // Se for isento, sempre está dispensado + if (dispensa.isento) { + return { + dispensado: true, + dispensa, + motivo: 'Isento de registro (caso excepcional)', + }; + } + + // Verificar se está no período + const dataInicio = new Date(dispensa.dataInicio); + const dataFim = new Date(dispensa.dataFim); + + // Se a data está dentro do período + if (dataConsulta >= dataInicio && dataConsulta <= dataFim) { + // Se hora e minuto foram fornecidos, verificar também + if (args.hora !== undefined && args.minuto !== undefined) { + const timestampConsulta = new Date( + `${args.data}T${args.hora.toString().padStart(2, '0')}:${args.minuto.toString().padStart(2, '0')}:00` + ).getTime(); + const timestampInicio = new Date( + `${dispensa.dataInicio}T${dispensa.horaInicio.toString().padStart(2, '0')}:${dispensa.minutoInicio.toString().padStart(2, '0')}:00` + ).getTime(); + const timestampFim = new Date( + `${dispensa.dataFim}T${dispensa.horaFim.toString().padStart(2, '0')}:${dispensa.minutoFim.toString().padStart(2, '0')}:00` + ).getTime(); + + if (timestampConsulta >= timestampInicio && timestampConsulta <= timestampFim) { + return { + dispensado: true, + dispensa, + motivo: dispensa.motivo, + }; + } + } else { + // Apenas verificar data + return { + dispensado: true, + dispensa, + motivo: dispensa.motivo, + }; + } + } + } + + return { + dispensado: false, + dispensa: null, + motivo: null, + }; + }, +}); + diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index cbcc444..1d301ab 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -1390,6 +1390,10 @@ export default defineSchema({ // Justificativa opcional para o registro justificativa: v.optional(v.string()), + // Campos para homologação + editadoPorGestor: v.optional(v.boolean()), + homologacaoId: v.optional(v.id("homologacoesPonto")), + criadoEm: v.number(), }) .index("by_funcionario_data", ["funcionarioId", "data"]) @@ -1443,4 +1447,60 @@ export default defineSchema({ .index("by_funcionario_data", ["funcionarioId", "data"]) .index("by_funcionario", ["funcionarioId"]) .index("by_data", ["data"]), + + // Homologações de Ponto - Edições e ajustes realizados pelo gestor + homologacoesPonto: defineTable({ + registroId: v.optional(v.id("registrosPonto")), // ID do registro editado (se for edição) + funcionarioId: v.id("funcionarios"), + gestorId: v.id("usuarios"), + // Dados do registro original (se for edição) + horaAnterior: v.optional(v.number()), + minutoAnterior: v.optional(v.number()), + // Dados do registro novo (se for edição) + horaNova: v.optional(v.number()), + minutoNova: v.optional(v.number()), + // Motivo e observações + motivoId: v.optional(v.string()), // ID do motivo (referência a atestados/declarações) + motivoTipo: v.optional(v.string()), // Tipo do motivo (atestado, declaracao, etc) + motivoDescricao: v.optional(v.string()), // Descrição do motivo + observacoes: v.optional(v.string()), + // Tipo de ajuste (se for ajuste de banco de horas) + tipoAjuste: v.optional(v.union( + v.literal("compensar"), + v.literal("abonar"), + v.literal("descontar") + )), + // Período do ajuste (se for ajuste de banco de horas) + periodoDias: v.optional(v.number()), + periodoHoras: v.optional(v.number()), + periodoMinutos: v.optional(v.number()), + // Ajuste em minutos (calculado) + ajusteMinutos: v.optional(v.number()), + criadoEm: v.number(), + }) + .index("by_funcionario", ["funcionarioId"]) + .index("by_gestor", ["gestorId"]) + .index("by_registro", ["registroId"]) + .index("by_data", ["criadoEm"]), + + // Dispensas de Registro - Períodos onde funcionário está dispensado de registrar ponto + dispensasRegistro: defineTable({ + funcionarioId: v.id("funcionarios"), + gestorId: v.id("usuarios"), + dataInicio: v.string(), // YYYY-MM-DD + horaInicio: v.number(), + minutoInicio: v.number(), + dataFim: v.string(), // YYYY-MM-DD + horaFim: v.number(), + minutoFim: v.number(), + motivo: v.string(), + isento: v.boolean(), // Se true, não expira (casos excepcionais) + ativo: v.boolean(), + criadoEm: v.number(), + }) + .index("by_funcionario", ["funcionarioId"]) + .index("by_gestor", ["gestorId"]) + .index("by_ativo", ["ativo"]) + .index("by_data_inicio", ["dataInicio"]) + .index("by_data_fim", ["dataFim"]), });