diff --git a/apps/web/src/lib/components/ponto/RegistroPonto.svelte b/apps/web/src/lib/components/ponto/RegistroPonto.svelte index 7c47383..07ca6cc 100644 --- a/apps/web/src/lib/components/ponto/RegistroPonto.svelte +++ b/apps/web/src/lib/components/ponto/RegistroPonto.svelte @@ -13,17 +13,7 @@ getTipoRegistroLabel, getProximoTipoRegistro } from '$lib/utils/ponto'; - import { - LogIn, - LogOut, - Clock, - CheckCircle2, - XCircle, - TrendingUp, - TrendingDown, - Printer, - Camera - } from 'lucide-svelte'; + import { LogIn, LogOut, Clock, CheckCircle2, XCircle, TrendingUp, TrendingDown, Printer, Camera } from 'lucide-svelte'; import type { Id } from '@sgse-app/backend/convex/_generated/dataModel'; import jsPDF from 'jspdf'; import logoGovPE from '$lib/assets/logo_governo_PE.png'; @@ -37,17 +27,17 @@ // Queries const currentUser = useQuery(api.auth.getCurrentUser, {}); const configQuery = useQuery(api.configuracaoPonto.obterConfiguracao, {}); - + // Query para histórico e saldo do dia const funcionarioId = $derived(currentUser?.data?.funcionarioId ?? null); const dataHoje = $derived(new Date().toISOString().split('T')[0]!); - + // Usar refreshKey para forçar atualização após registro - const registrosHojeQuery = useQuery(api.pontos.listarRegistrosDia, { - data: dataHoje, - _refresh: refreshKey - }); - + const registrosHojeQuery = useQuery( + api.pontos.listarRegistrosDia, + { data: dataHoje, _refresh: refreshKey } + ); + const historicoSaldoQuery = useQuery( api.pontos.obterHistoricoESaldoDia, funcionarioId && dataHoje ? { funcionarioId, data: dataHoje, _refresh: refreshKey } : 'skip' @@ -76,9 +66,7 @@ let mostrandoTransicao = $state(false); // Novo estado para transição let dataHoraAtual = $state<{ data: string; hora: string } | null>(null); let aguardandoProcessamento = $state(false); - let etapaProcessamento = $state<'coletando' | 'sincronizando' | 'upload' | 'registrando' | null>( - null - ); + let etapaProcessamento = $state<'coletando' | 'sincronizando' | 'upload' | 'registrando' | null>(null); const registrosHoje = $derived(registrosHojeQuery?.data || []); const config = $derived(configQuery?.data); @@ -97,7 +85,7 @@ nomeEntrada: config.nomeEntrada, nomeSaidaAlmoco: config.nomeSaidaAlmoco, nomeRetornoAlmoco: config.nomeRetornoAlmoco, - nomeSaida: config.nomeSaida + nomeSaida: config.nomeSaida, }); } return getTipoRegistroLabel(proximoTipo); @@ -132,8 +120,8 @@ } // Verificar permissões de localização e webcam - async function verificarPermissoes(): Promise<{ - localizacao: boolean; + async function verificarPermissoes(): Promise<{ + localizacao: boolean; webcam: boolean; permissoesNecessarias: string[]; }> { @@ -176,7 +164,7 @@ const stream = await navigator.mediaDevices.getUserMedia({ video: true }); webcamAutorizada = true; // Parar o stream imediatamente, apenas verificamos a permissão - stream.getTracks().forEach((track) => track.stop()); + stream.getTracks().forEach(track => track.stop()); } catch (error) { console.warn('Permissão de webcam não concedida:', error); permissoesNecessarias.push('câmera'); @@ -192,8 +180,7 @@ // Verificar se tem funcionário associado if (!temFuncionarioAssociado) { mensagemErroModal = 'Usuário não possui funcionário associado'; - detalhesErroModal = - 'Você não possui um funcionário associado à sua conta. Entre em contato com o administrador do sistema.'; + detalhesErroModal = 'Você não possui um funcionário associado à sua conta. Entre em contato com o administrador do sistema.'; mostrarModalErro = true; return; } @@ -227,7 +214,7 @@ etapaProcessamento = 'coletando'; const informacoesDispositivo = await obterInformacoesDispositivo(); // Nota: A permissão de sensor não é impeditiva - apenas câmera e localização são obrigatórias - + coletandoInfo = false; // Obter tempo sincronizado e aplicar GMT offset (igual ao relógio) @@ -235,9 +222,9 @@ const configRelogio = await client.query(api.configuracaoRelogio.obterConfiguracao, {}); // Usar gmtOffset da configuração, sem valor padrão, pois 0 é um valor válido const gmtOffset = configRelogio.gmtOffset ?? 0; - + let timestampBase: number; - + if (configRelogio.usarServidorExterno) { try { const resultado = await client.action(api.configuracaoRelogio.sincronizarTempo, {}); @@ -258,25 +245,24 @@ // Usar relógio do PC (sem sincronização com servidor) timestampBase = Date.now(); } - + // Aplicar GMT offset ao timestamp // Quando GMT é 0, compensar o timezone local do navegador para que o timestamp // represente o horário local (não UTC), evitando que apareça 3 horas a mais let timestamp: number; if (gmtOffset !== 0) { // Aplicar offset configurado - timestamp = timestampBase + gmtOffset * 60 * 60 * 1000; + timestamp = timestampBase + (gmtOffset * 60 * 60 * 1000); } else { // Quando GMT = 0, ajustar para horário local do navegador // getTimezoneOffset() retorna minutos POSITIVOS para fusos ATRÁS de UTC // Exemplo: Brasil (UTC-3) retorna 180 minutos // Subtrair esses minutos para que o timestamp represente o horário local const timezoneOffset = new Date().getTimezoneOffset(); // Offset em minutos - timestamp = timestampBase - timezoneOffset * 60 * 1000; // Subtrair minutos em milissegundos + timestamp = timestampBase - (timezoneOffset * 60 * 1000); // Subtrair minutos em milissegundos } // Sincronizado apenas se usar servidor externo e sincronização foi bem-sucedida - const sincronizadoComServidor = - configRelogio.usarServidorExterno && timestampBase !== Date.now(); + const sincronizadoComServidor = configRelogio.usarServidorExterno && timestampBase !== Date.now(); // Upload da imagem (obrigatória agora) let imagemId: Id<'_storage'> | undefined = undefined; @@ -308,7 +294,7 @@ nomeEntrada: config.nomeEntrada, nomeSaidaAlmoco: config.nomeSaidaAlmoco, nomeRetornoAlmoco: config.nomeRetornoAlmoco, - nomeSaida: config.nomeSaida + nomeSaida: config.nomeSaida, }) : getTipoRegistroLabel(resultado.tipo); sucesso = `Ponto registrado com sucesso! Tipo: ${tipoLabelSucesso}`; @@ -318,14 +304,14 @@ // Forçar atualização das queries para mostrar o novo registro refreshKey++; - + if (import.meta.env.DEV) { console.log('[RegistroPonto] Registro bem-sucedido, refreshKey incrementado:', refreshKey); } - + // Aguardar um pouco para garantir que o backend processou o registro - await new Promise((resolve) => setTimeout(resolve, 500)); - + await new Promise(resolve => setTimeout(resolve, 500)); + // Forçar mais uma atualização após o delay para garantir sincronização refreshKey++; @@ -339,10 +325,10 @@ etapaProcessamento = null; let mensagemErro = 'Erro desconhecido ao registrar ponto'; let detalhesErro = 'Tente novamente em alguns instantes.'; - + if (error instanceof Error) { const erroMessage = error.message || ''; - + // Erro de registro duplicado if ( erroMessage.includes('Já existe um registro neste minuto') || @@ -354,7 +340,7 @@ nomeEntrada: config.nomeEntrada, nomeSaidaAlmoco: config.nomeSaidaAlmoco, nomeRetornoAlmoco: config.nomeRetornoAlmoco, - nomeSaida: config.nomeSaida + nomeSaida: config.nomeSaida, }) : getTipoRegistroLabel(proximoTipo); detalhesErro = `Não é possível registrar o ponto no mesmo minuto.\n\nVocê já possui um registro de ${tipoLabelErro} para este minuto.\n\nPor favor, aguarde pelo menos 1 minuto antes de tentar registrar novamente.`; @@ -366,8 +352,7 @@ erroMessage.includes('validation') ) { mensagemErro = 'Erro na validação dos dados'; - detalhesErro = - 'Ocorreu um erro ao validar as informações do dispositivo.\n\nPor favor, tente novamente ou recarregue a página.'; + detalhesErro = 'Ocorreu um erro ao validar as informações do dispositivo.\n\nPor favor, tente novamente ou recarregue a página.'; } // Erro de autenticação else if ( @@ -385,14 +370,12 @@ erroMessage.includes('location') ) { mensagemErro = 'Erro na validação de localização'; - detalhesErro = - 'Não foi possível validar sua localização.\n\nPor favor, verifique se você autorizou o compartilhamento de localização e tente novamente.'; + detalhesErro = 'Não foi possível validar sua localização.\n\nPor favor, verifique se você autorizou o compartilhamento de localização e tente novamente.'; } // Erro genérico do servidor else if (erroMessage.includes('Server Error') || erroMessage.includes('Server')) { mensagemErro = 'Erro no servidor'; - detalhesErro = - 'Ocorreu um erro no servidor ao processar seu registro.\n\nPor favor, tente novamente em alguns instantes.'; + detalhesErro = 'Ocorreu um erro no servidor ao processar seu registro.\n\nPor favor, tente novamente em alguns instantes.'; } // Outros erros - mostrar mensagem simplificada else { @@ -404,14 +387,13 @@ erroMessage.includes('ReferenceError') || erroMessage.length > 200 ) { - detalhesErro = - 'Ocorreu um erro ao processar o registro.\n\nPor favor, tente novamente ou recarregue a página.'; + detalhesErro = 'Ocorreu um erro ao processar o registro.\n\nPor favor, tente novamente ou recarregue a página.'; } else { detalhesErro = erroMessage; } } } - + mensagemErroModal = mensagemErro; detalhesErroModal = detalhesErro; mostrarModalErro = true; @@ -429,16 +411,64 @@ } mostrandoWebcam = false; - // Se capturou a foto, iniciar o processo automaticamente - if (blob) { + // Se capturou a foto, mostrar modal de confirmação + if (blob && capturandoAutomaticamente) { capturandoAutomaticamente = false; - // Mostrar tela de transição e iniciar o processo automaticamente + // Obter data e hora sincronizada do servidor com GMT offset (igual ao relógio) + try { + const configRelogio = await client.query(api.configuracaoRelogio.obterConfiguracao, {}); + // Usar gmtOffset da configuração, sem valor padrão, pois 0 é um valor válido + const gmtOffset = configRelogio.gmtOffset ?? 0; + + let timestampBase: number; + + if (configRelogio.usarServidorExterno) { + try { + const resultado = await client.action(api.configuracaoRelogio.sincronizarTempo, {}); + if (resultado.sucesso && resultado.timestamp) { + timestampBase = resultado.timestamp; + } else { + timestampBase = await obterTempoServidor(client); + } + } catch (error) { + console.warn('Erro ao sincronizar com servidor externo:', error); + if (configRelogio.fallbackParaPC) { + timestampBase = Date.now(); + } else { + timestampBase = await obterTempoServidor(client); + } + } + } else { + // Usar relógio do PC (sem sincronização com servidor) + timestampBase = Date.now(); + } + + // Aplicar GMT offset ao timestamp + // Quando GMT é 0, usar timestamp UTC puro e deixar toLocaleTimeString() fazer a conversão automática + // Quando GMT ≠ 0, aplicar offset configurado ao timestamp + let timestamp: number; + if (gmtOffset !== 0) { + // Aplicar offset configurado + timestamp = timestampBase + (gmtOffset * 60 * 60 * 1000); + } else { + // Quando GMT = 0, manter timestamp UTC puro + // O toLocaleTimeString() converterá automaticamente para o timezone local do navegador + timestamp = timestampBase; + } + const dataObj = new Date(timestamp); + const data = dataObj.toLocaleDateString('pt-BR'); + const hora = dataObj.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + dataHoraAtual = { data, hora }; + } catch (error) { + console.warn('Erro ao obter tempo do servidor, usando tempo local:', error); + atualizarDataHoraAtual(); + } + + // Mostrar transição antes da confirmação mostrandoTransicao = true; - - // Aguardar 1.5s mostrando a mensagem de "Registro em Andamento" e então confirmar setTimeout(() => { mostrandoTransicao = false; - confirmarRegistro(); + mostrandoModalConfirmacao = true; }, 1500); } } @@ -464,11 +494,7 @@ function atualizarDataHoraAtual() { const agora = new Date(); const data = agora.toLocaleDateString('pt-BR'); - const hora = agora.toLocaleTimeString('pt-BR', { - hour: '2-digit', - minute: '2-digit', - second: '2-digit' - }); + const hora = agora.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); dataHoraAtual = { data, hora }; } @@ -478,8 +504,7 @@ // Verificar se tem funcionário associado if (!temFuncionarioAssociado) { mensagemErroModal = 'Usuário não possui funcionário associado'; - detalhesErroModal = - 'Você não possui um funcionário associado à sua conta. Entre em contato com o administrador do sistema.'; + detalhesErroModal = 'Você não possui um funcionário associado à sua conta. Entre em contato com o administrador do sistema.'; mostrarModalErro = true; return; } @@ -496,8 +521,7 @@ const permissoes = await verificarPermissoes(); if (!permissoes.localizacao || !permissoes.webcam) { mensagemErroModal = 'Permissões necessárias'; - detalhesErroModal = - 'Para registrar o ponto, é necessário autorizar o compartilhamento de localização e a captura de foto.'; + detalhesErroModal = 'Para registrar o ponto, é necessário autorizar o compartilhamento de localização e a captura de foto.'; mostrarModalErro = true; return; } @@ -509,13 +533,17 @@ function confirmarRegistro() { mostrandoModalConfirmacao = false; - mostrandoTransicao = false; - aguardandoProcessamento = true; - etapaProcessamento = 'coletando'; - // Usar setTimeout para garantir que o modal de processamento apareça antes de iniciar o registro + mostrandoTransicao = true; // Mostrar transição antes do processamento + setTimeout(() => { - registrarPonto(); - }, 100); + mostrandoTransicao = false; + aguardandoProcessamento = true; + etapaProcessamento = 'coletando'; + // Usar setTimeout para garantir que o modal de processamento apareça antes de iniciar o registro + setTimeout(() => { + registrarPonto(); + }, 100); + }, 1500); } function cancelarRegistro() { @@ -538,7 +566,7 @@ try { // Buscar dados completos do registro const registro = await client.query(api.pontos.obterRegistro, { registroId }); - + if (!registro) { alert('Registro não encontrado'); return; @@ -615,26 +643,17 @@ nomeEntrada: configComprovante.nomeEntrada, nomeSaidaAlmoco: configComprovante.nomeSaidaAlmoco, nomeRetornoAlmoco: configComprovante.nomeRetornoAlmoco, - nomeSaida: configComprovante.nomeSaida + nomeSaida: configComprovante.nomeSaida, }) : getTipoRegistroLabel(registro.tipo); doc.text(`Tipo: ${tipoLabelComprovante}`, 15, yPosition); yPosition += 6; - const dataHora = formatarDataHoraCompleta( - registro.data, - registro.hora, - registro.minuto, - registro.segundo - ); + const dataHora = formatarDataHoraCompleta(registro.data, registro.hora, registro.minuto, registro.segundo); doc.text(`Data e Hora: ${dataHora}`, 15, yPosition); yPosition += 6; - doc.text( - `Status: ${registro.dentroDoPrazo ? 'Dentro do Prazo' : 'Fora do Prazo'}`, - 15, - yPosition - ); + doc.text(`Status: ${registro.dentroDoPrazo ? 'Dentro do Prazo' : 'Fora do Prazo'}`, 15, yPosition); yPosition += 6; doc.text(`Tolerância: ${registro.toleranciaMinutos} minutos`, 15, yPosition); @@ -666,10 +685,10 @@ if (!response.ok) { throw new Error('Erro ao carregar imagem'); } - + const blob = await response.blob(); const reader = new FileReader(); - + // Converter blob para base64 const base64 = await new Promise((resolve, reject) => { reader.onloadend = () => { @@ -713,7 +732,7 @@ // Centralizar imagem const xPosition = (doc.internal.pageSize.getWidth() - imgWidth) / 2; - + // Verificar se cabe na página atual if (yPosition + imgHeight > doc.internal.pageSize.getHeight() - 20) { doc.addPage(); @@ -760,13 +779,7 @@ const temFuncionarioAssociado = $derived(funcionarioId !== null); const podeRegistrar = $derived.by(() => { - return ( - !registrando && - !coletandoInfo && - config !== undefined && - !estaDispensado && - temFuncionarioAssociado - ); + return !registrando && !coletandoInfo && config !== undefined && !estaDispensado && temFuncionarioAssociado; }); // Os modais agora são centralizados automaticamente via CSS (fixed inset-0 flex items-center justify-center) @@ -814,16 +827,8 @@ const horarios = [ { tipo: 'entrada', horario: config.horarioEntrada, label: config.nomeEntrada || 'Entrada 1' }, - { - tipo: 'saida_almoco', - horario: config.horarioSaidaAlmoco, - label: config.nomeSaidaAlmoco || 'Saída 1' - }, - { - tipo: 'retorno_almoco', - horario: config.horarioRetornoAlmoco, - label: config.nomeRetornoAlmoco || 'Entrada 2' - }, + { tipo: 'saida_almoco', horario: config.horarioSaidaAlmoco, label: config.nomeSaidaAlmoco || 'Saída 1' }, + { tipo: 'retorno_almoco', horario: config.horarioRetornoAlmoco, label: config.nomeRetornoAlmoco || 'Entrada 2' }, { tipo: 'saida', horario: config.horarioSaida, label: config.nomeSaida || 'Saída 2' } ]; @@ -841,8 +846,8 @@ if (import.meta.env.DEV) { console.log('[RegistroPonto] mapaHorarios atualizado:', { totalRegistrosHoje: registrosHoje.length, - horariosComRegistro: resultado.filter((h) => h.registrado).length, - registrosHoje: registrosHoje.map((r) => ({ tipo: r.tipo, hora: `${r.hora}:${r.minuto}` })) + horariosComRegistro: resultado.filter(h => h.registrado).length, + registrosHoje: registrosHoje.map(r => ({ tipo: r.tipo, hora: `${r.hora}:${r.minuto}` })) }); } @@ -874,77 +879,10 @@ // Posicionamento dos modais // Posicionamento dos modais - let modalPosition = $state<{ top: number; left: number } | null>(null); + // Removido modalPosition pois agora será centralizado via CSS fixo - // Função para calcular a posição baseada no card de registro de ponto - function calcularPosicaoModal() { - // Procurar pelo elemento do card de registro de ponto - const cardRef = document.getElementById('card-registro-ponto-ref'); - - if (cardRef) { - const rect = cardRef.getBoundingClientRect(); - - // Posicionar o modal centralizado horizontalmente na tela - const left = window.innerWidth / 2; - // Posicionar no topo do card - const top = rect.top; - - return { - top: top, - left: left - }; - } - - // Se não encontrar, usar posição padrão (centro da tela) - return null; - } - - // Atualizar posição quando os modais forem abertos ou quando a página rolar - $effect(() => { - if ( - mostrandoWebcam || - mostrandoModalConfirmacao || - aguardandoProcessamento || - mostrarModalErro || - mostrandoTransicao - ) { - // Usar requestAnimationFrame para garantir que o DOM está completamente renderizado - const updatePosition = () => { - requestAnimationFrame(() => { - const pos = calcularPosicaoModal(); - if (pos) { - modalPosition = pos; - } - }); - }; - - // Aguardar um pouco mais para garantir que o DOM está atualizado - setTimeout(updatePosition, 50); - - // Adicionar listener de scroll para atualizar posição - const handleScroll = () => { - updatePosition(); - }; - - window.addEventListener('scroll', handleScroll, true); - window.addEventListener('resize', handleScroll); - - return () => { - window.removeEventListener('scroll', handleScroll, true); - window.removeEventListener('resize', handleScroll); - }; - } else { - // Limpar posição quando os modais forem fechados - modalPosition = null; - } - }); - - // Função para obter estilo do modal + // Função para obter estilo do modal (centralizado) function getModalStyle() { - if (modalPosition) { - const top = Math.max(20, modalPosition.top); // Garantir margem mínima - return `position: fixed; top: ${top}px; left: ${modalPosition.left}px; transform: translateX(-50%); width: 100%; max-width: 800px;`; - } return 'position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 100%; max-width: 800px;'; } @@ -998,45 +936,39 @@
Você está dispensado de registrar ponto no momento.
- Motivo: - {motivoDispensa} + Motivo: {motivoDispensa}
{/if} -
+
-
-
- +
+
+
-

Registrar Ponto

+

Registrar Ponto

-
+