From fc4b5c5ba56c282d50be1fa7e03d1395294a3259 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Sat, 22 Nov 2025 19:32:05 -0300 Subject: [PATCH] feat: add date formatting utility and enhance filtering in registro-pontos - Introduced a new utility function `formatarDataDDMMAAAA` to format dates in DD/MM/AAAA format, supporting various input types. - Updated the `registro-pontos` page to utilize the new date formatting function for displaying dates consistently. - Implemented advanced filtering options for status and location, allowing users to filter records based on their criteria. - Enhanced CSV export functionality to include formatted dates and additional filtering capabilities, improving data management for users. --- .../components/ponto/LocalizacaoIcon.svelte | 26 ++ .../components/ponto/SaldoDiarioBadge.svelte | 36 +++ apps/web/src/lib/utils/ponto.ts | 34 +++ .../registro-pontos/+page.svelte | 275 ++++++++++++++---- 4 files changed, 322 insertions(+), 49 deletions(-) create mode 100644 apps/web/src/lib/components/ponto/LocalizacaoIcon.svelte create mode 100644 apps/web/src/lib/components/ponto/SaldoDiarioBadge.svelte diff --git a/apps/web/src/lib/components/ponto/LocalizacaoIcon.svelte b/apps/web/src/lib/components/ponto/LocalizacaoIcon.svelte new file mode 100644 index 0000000..2687903 --- /dev/null +++ b/apps/web/src/lib/components/ponto/LocalizacaoIcon.svelte @@ -0,0 +1,26 @@ + + +{#if dentroRaioPermitido === true} +
+ +
+{:else if dentroRaioPermitido === false} +
+ +
+{:else} +
+ +
+{/if} + + diff --git a/apps/web/src/lib/components/ponto/SaldoDiarioBadge.svelte b/apps/web/src/lib/components/ponto/SaldoDiarioBadge.svelte new file mode 100644 index 0000000..f7c5217 --- /dev/null +++ b/apps/web/src/lib/components/ponto/SaldoDiarioBadge.svelte @@ -0,0 +1,36 @@ + + +{#if saldo} + + {formatarSaldo(saldo)} + +{:else} + - +{/if} diff --git a/apps/web/src/lib/utils/ponto.ts b/apps/web/src/lib/utils/ponto.ts index 0015069..d6384c3 100644 --- a/apps/web/src/lib/utils/ponto.ts +++ b/apps/web/src/lib/utils/ponto.ts @@ -122,3 +122,37 @@ export function getProximoTipoRegistro( } } +/** + * Formata data no formato DD/MM/AAAA + * Suporta strings ISO (YYYY-MM-DD), objetos Date, e timestamps + */ +export function formatarDataDDMMAAAA(data: string | Date | number): string { + if (!data) return ''; + + let dataObj: Date; + + if (typeof data === 'string') { + // Se for string no formato ISO (YYYY-MM-DD), adicionar hora para evitar problemas de timezone + if (data.match(/^\d{4}-\d{2}-\d{2}$/)) { + dataObj = new Date(data + 'T12:00:00'); + } else { + dataObj = new Date(data); + } + } else if (typeof data === 'number') { + dataObj = new Date(data); + } else { + dataObj = data; + } + + // Verificar se a data é válida + if (isNaN(dataObj.getTime())) { + return ''; + } + + const dia = dataObj.getDate().toString().padStart(2, '0'); + const mes = (dataObj.getMonth() + 1).toString().padStart(2, '0'); + const ano = dataObj.getFullYear(); + + return `${dia}/${mes}/${ano}`; +} + 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 5a9cbca..03b9e4f 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 @@ -4,13 +4,16 @@ import { api } from '@sgse-app/backend/convex/_generated/api'; import { Clock, Filter, Download, Printer, BarChart3, Users, CheckCircle2, XCircle, TrendingUp, TrendingDown, FileText } from 'lucide-svelte'; import type { Id } from '@sgse-app/backend/convex/_generated/dataModel'; - import { formatarHoraPonto, getTipoRegistroLabel } from '$lib/utils/ponto'; + import { formatarHoraPonto, getTipoRegistroLabel, formatarDataDDMMAAAA } from '$lib/utils/ponto'; + import LocalizacaoIcon from '$lib/components/ponto/LocalizacaoIcon.svelte'; + import SaldoDiarioBadge from '$lib/components/ponto/SaldoDiarioBadge.svelte'; 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'; import { Chart, registerables } from 'chart.js'; + import Papa from 'papaparse'; Chart.register(...registerables); @@ -20,6 +23,8 @@ let dataInicio = $state(new Date().toISOString().split('T')[0]!); let dataFim = $state(new Date().toISOString().split('T')[0]!); let funcionarioIdFiltro = $state | ''>(''); + let statusFiltro = $state<'todos' | 'dentro' | 'fora'>('todos'); + let localizacaoFiltro = $state<'todos' | 'dentro' | 'fora'>('todos'); let carregando = $state(false); let mostrarModalImpressao = $state(false); let funcionarioParaImprimir = $state | ''>(''); @@ -209,6 +214,33 @@ } }); + // Filtrar registros com base nos filtros avançados + const registrosFiltrados = $derived.by(() => { + if (!registros || registros.length === 0) return []; + + let resultado = [...registros]; + + // Filtro de status (Dentro/Fora do Prazo) + if (statusFiltro !== 'todos') { + resultado = resultado.filter(r => { + if (statusFiltro === 'dentro') return r.dentroDoPrazo === true; + if (statusFiltro === 'fora') return r.dentroDoPrazo === false; + return true; + }); + } + + // Filtro de localização (Dentro/Fora do Raio) + if (localizacaoFiltro !== 'todos') { + resultado = resultado.filter(r => { + if (localizacaoFiltro === 'dentro') return r.dentroRaioPermitido === true; + if (localizacaoFiltro === 'fora') return r.dentroRaioPermitido === false; + return true; + }); + } + + return resultado; + }); + // Agrupar registros por funcionário e data const registrosAgrupados = $derived.by(() => { const agrupados: Record< @@ -230,12 +262,15 @@ // Usar Set para evitar registros duplicados const registrosProcessados = new Set(); + // Usar registros filtrados ao invés de registros originais + const registrosParaAgrupar = registrosFiltrados; + // Verificar se registros é um array válido - if (!Array.isArray(registros) || registros.length === 0) { + if (!Array.isArray(registrosParaAgrupar) || registrosParaAgrupar.length === 0) { return []; } - for (const registro of registros) { + for (const registro of registrosParaAgrupar) { // Verificar se o registro tem os campos necessários if (!registro || !registro._id || !registro.funcionarioId || !registro.data) { console.warn('⚠️ [DEBUG] Registro inválido ignorado:', registro); @@ -345,16 +380,7 @@ return `${sinal}${saldo.horas}h ${saldo.minutos}min`; } - // Função para formatar data em português - function formatarData(data: string): string { - if (!data) return ''; - const dataObj = new Date(data + 'T00:00:00'); - return dataObj.toLocaleDateString('pt-BR', { - day: '2-digit', - month: '2-digit', - year: 'numeric' - }); - } + // Usar função centralizada formatarDataDDMMAAAA da lib/utils/ponto.ts // Obter nome do funcionário selecionado const funcionarioSelecionadoNome = $derived.by(() => { @@ -407,6 +433,93 @@ mostrarModalImpressao = true; } + // Função para limpar todos os filtros + function limparFiltros() { + dataInicio = new Date().toISOString().split('T')[0]!; + dataFim = new Date().toISOString().split('T')[0]!; + funcionarioIdFiltro = ''; + statusFiltro = 'todos'; + localizacaoFiltro = 'todos'; + toast.success('Filtros limpos com sucesso!'); + } + + // Função para exportar registros para CSV + async function exportarCSV() { + try { + const registrosParaExportar = registrosFiltrados; + + if (!registrosParaExportar || registrosParaExportar.length === 0) { + toast.error('Nenhum registro para exportar'); + return; + } + + // Preparar dados para CSV + const csvData = registrosParaExportar.map((registro) => { + const funcionarioNome = registro.funcionario?.nome || 'N/A'; + const funcionarioMatricula = registro.funcionario?.matricula || 'N/A'; + const tipo = config + ? getTipoRegistroLabel(registro.tipo, { + nomeEntrada: config.nomeEntrada, + nomeSaidaAlmoco: config.nomeSaidaAlmoco, + nomeRetornoAlmoco: config.nomeRetornoAlmoco, + nomeSaida: config.nomeSaida, + }) + : getTipoRegistroLabel(registro.tipo); + + const horario = formatarHoraPonto(registro.hora, registro.minuto); + const status = registro.dentroDoPrazo ? 'Dentro do Prazo' : 'Fora do Prazo'; + const localizacao = registro.dentroRaioPermitido === true + ? 'Dentro do Raio' + : registro.dentroRaioPermitido === false + ? 'Fora do Raio' + : 'Não Validado'; + + return { + 'Data': formatarDataDDMMAAAA(registro.data), + 'Funcionário': funcionarioNome, + 'Matrícula': funcionarioMatricula, + 'Tipo': tipo, + 'Horário': horario, + 'Status': status, + 'Localização': localizacao, + 'IP': registro.ipAddress || 'N/A', + 'Dispositivo': registro.deviceType || 'N/A', + }; + }); + + // Gerar CSV usando Papa Parse + const csv = Papa.unparse(csvData, { + header: true, + delimiter: ';', + encoding: 'UTF-8', + }); + + // Adicionar BOM para Excel reconhecer UTF-8 corretamente + const BOM = '\uFEFF'; + const csvComBOM = BOM + csv; + + // Criar blob e download + const blob = new Blob([csvComBOM], { type: 'text/csv;charset=utf-8;' }); + const link = document.createElement('a'); + const url = URL.createObjectURL(blob); + link.setAttribute('href', url); + link.setAttribute( + 'download', + `registros-ponto-${formatarDataDDMMAAAA(dataInicio)}-${formatarDataDDMMAAAA(dataFim)}.csv` + ); + link.style.visibility = 'hidden'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + + toast.success('CSV exportado com sucesso!'); + } catch (error) { + console.error('Erro ao exportar CSV:', error); + toast.error('Erro ao exportar CSV. Tente novamente.'); + } + } + async function gerarPDFComSelecao(sections: { dadosFuncionario: boolean; registrosPonto: boolean; @@ -497,10 +610,8 @@ } 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]}`; + // Formatar período para exibição usando função centralizada + const periodoFormatado = `${formatarDataDDMMAAAA(dataInicio)} a ${formatarDataDDMMAAAA(dataFim)}`; doc.text(`Período: ${periodoFormatado}`, 15, yPosition); yPosition += 10; } @@ -571,6 +682,7 @@ hora: number; minuto: number; dentroDoPrazo: boolean; + dentroRaioPermitido: boolean | null | undefined; }> > = {}; @@ -585,14 +697,14 @@ hora: r.hora, minuto: r.minuto, dentroDoPrazo: r.dentroDoPrazo, + dentroRaioPermitido: r.dentroRaioPermitido, }); } // 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]}`; + // Formatar data para exibição usando função centralizada (DD/MM/AAAA) + const dataFormatada = formatarDataDDMMAAAA(data); // Calcular saldo diário como diferença entre saída e entrada const saldoDiarioDia = calcularSaldoDiario(regs); @@ -621,6 +733,15 @@ } } + // Adicionar localização (geofencing) + if (reg.dentroRaioPermitido === true) { + linha.push('✅ Dentro do Raio'); + } else if (reg.dentroRaioPermitido === false) { + linha.push('⚠️ Fora do Raio'); + } else { + linha.push('❓ Não Validado'); + } + linha.push(reg.dentroDoPrazo ? 'Sim' : 'Não'); tableData.push(linha); @@ -631,6 +752,7 @@ if (sections.saldoDiario) { headers.push('Saldo Diário'); } + headers.push('Localização'); headers.push('Dentro do Prazo'); // Salvar a posição Y antes da tabela @@ -740,9 +862,8 @@ 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()}`; + // Formatar data de criação usando função centralizada (DD/MM/AAAA) + const dataFormatada = formatarDataDDMMAAAA(h.criadoEm); if (h.registroId && h.horaAnterior !== undefined) { return [ @@ -804,13 +925,9 @@ 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]}`; + // Formatar data de início e fim usando função centralizada (DD/MM/AAAA) + const dataInicioFormatada = formatarDataDDMMAAAA(d.dataInicio); + const dataFimFormatada = formatarDataDDMMAAAA(d.dataFim); return [ `${dataInicioFormatada} ${d.horaInicio.toString().padStart(2, '0')}:${d.minutoInicio.toString().padStart(2, '0')}`, @@ -1805,13 +1922,34 @@
-
-
- +
+
+
+ +
+

Filtros de Busca

+
+
+ +
-

Filtros de Busca

-
+
+ +
+ + +
+ +
+ + +
+ + + {#if statusFiltro !== 'todos' || localizacaoFiltro !== 'todos'} +
+

+ {registrosFiltrados.length} registro(s) encontrado(s) com os filtros aplicados + {#if registros.length !== registrosFiltrados.length} + + (de {registros.length} total) + + {/if} +

+
+ {/if}
@@ -1878,13 +2060,13 @@ {#if dataInicio}
- De: {formatarData(dataInicio)} + De: {formatarDataDDMMAAAA(dataInicio)}
{/if} {#if dataFim}
- Até: {formatarData(dataFim)} + Até: {formatarDataDDMMAAAA(dataFim)}
{/if}
@@ -1916,7 +2098,7 @@

Nenhum registro encontrado

-

Período: {formatarData(dataInicio)} até {formatarData(dataFim)}

+

Período: {formatarDataDDMMAAAA(dataInicio)} até {formatarDataDDMMAAAA(dataFim)}

{#if funcionarioIdFiltro && funcionarioSelecionadoNome}

Funcionário: {funcionarioSelecionadoNome}

{/if} @@ -2011,6 +2193,7 @@ Tipo Horário Saldo Diário + Localização Status Ações @@ -2018,8 +2201,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]}`} + {@const dataFormatada = formatarDataDDMMAAAA(grupoData.data)} {#each grupoData.registros as registro, index} {dataFormatada} @@ -2036,17 +2218,12 @@ {formatarHoraPonto(registro.hora, registro.minuto)} {#if index === 0} - {#if grupoData.saldoDiario} - - {formatarSaldoDiario(grupoData.saldoDiario)} - - {:else} - - - {/if} + {/if} + + +