diff --git a/apps/web/CONFIGURACAO_ENV.md b/apps/web/CONFIGURACAO_ENV.md new file mode 100644 index 0000000..91f3551 --- /dev/null +++ b/apps/web/CONFIGURACAO_ENV.md @@ -0,0 +1,29 @@ +# ⚙️ Configuração de Variáveis de Ambiente + +## 📁 Arquivo .env + +Crie um arquivo `.env` na pasta `apps/web/` com as seguintes variáveis: + +```env +# Google Maps API Key (opcional) +# Obtenha sua chave em: https://console.cloud.google.com/ +# Ative a "Geocoding API" para buscar coordenadas por endereço +# Deixe vazio para usar OpenStreetMap (gratuito, sem necessidade de chave) +VITE_GOOGLE_MAPS_API_KEY= + +# VAPID Public Key para Push Notifications (opcional) +VITE_VAPID_PUBLIC_KEY= +``` + +## 📖 Documentação Completa + +Para instruções detalhadas sobre como obter e configurar a Google Maps API Key, consulte: + +📄 **[GOOGLE_MAPS_SETUP.md](./GOOGLE_MAPS_SETUP.md)** + +## ⚠️ Importante + +- O arquivo `.env` não deve ser commitado no Git (já está no .gitignore) +- Variáveis de ambiente começam com `VITE_` para serem acessíveis no frontend +- Reinicie o servidor de desenvolvimento após alterar o arquivo `.env` + diff --git a/apps/web/GOOGLE_MAPS_SETUP.md b/apps/web/GOOGLE_MAPS_SETUP.md new file mode 100644 index 0000000..7ccfb87 --- /dev/null +++ b/apps/web/GOOGLE_MAPS_SETUP.md @@ -0,0 +1,174 @@ +# 📍 Configuração do Google Maps API para Busca de Coordenadas + +Este guia explica como configurar a API do Google Maps para obter coordenadas GPS de forma automática e precisa no sistema de Endereços de Marcação. + +## 🎯 Por que usar Google Maps? + +- ✅ **Maior Precisão**: Resultados mais exatos para endereços brasileiros +- ✅ **Melhor Cobertura**: Banco de dados mais completo e atualizado +- ✅ **Geocoding Avançado**: Entende melhor endereços incompletos ou parciais + +> **Nota**: O sistema funciona perfeitamente sem a API key do Google Maps, usando OpenStreetMap (gratuito). A configuração do Google Maps é opcional. + +--- + +## 📋 Passo a Passo + +### 1. Criar Projeto no Google Cloud Platform + +1. Acesse [Google Cloud Console](https://console.cloud.google.com/) +2. Clique em **"Criar Projeto"** ou selecione um projeto existente +3. Preencha o nome do projeto (ex: "SGSE-App") +4. Clique em **"Criar"** + +### 2. Ativar a Geocoding API + +1. No menu lateral, vá em **"APIs e Serviços"** > **"Biblioteca"** +2. Procure por **"Geocoding API"** +3. Clique no resultado e depois em **"Ativar"** +4. Aguarde alguns segundos para a ativação + +### 3. Criar Chave de API + +1. Ainda em **"APIs e Serviços"**, vá em **"Credenciais"** +2. Clique em **"Criar Credenciais"** > **"Chave de API"** +3. Copie a chave gerada (você precisará dela depois) + +### 4. Configurar Restrições de Segurança (Recomendado) + +Para proteger sua chave de API: + +1. Clique na chave criada para editá-la +2. Em **"Restrições de API"**: + - Selecione **"Restringir chave"** + - Escolha **"Geocoding API"** +3. Em **"Restrições de aplicativo"**: + - Para desenvolvimento local: escolha **"Referenciadores de sites HTTP"** + - Adicione: `http://localhost:*` e `http://127.0.0.1:*` + - Para produção: adicione o domínio do seu site +4. Clique em **"Salvar"** + +### 5. Configurar no Projeto + +1. No diretório `apps/web/`, copie o arquivo de exemplo: + ```bash + cp .env.example .env + ``` + +2. Abra o arquivo `.env` e adicione sua chave: + ```env + VITE_GOOGLE_MAPS_API_KEY=sua_chave_aqui + ``` + +3. Reinicie o servidor de desenvolvimento: + ```bash + npm run dev + ``` + +### 6. Verificar se está funcionando + +1. Acesse a página de **Endereços de Marcação** (`/ti/configuracoes-ponto/enderecos`) +2. Clique em **"Novo Endereço"** +3. Preencha um endereço e clique em **"Buscar GPS"** +4. Se configurado corretamente, verá a mensagem: *"Coordenadas encontradas via Google Maps!"* + +--- + +## 💰 Custos + +### Google Maps Geocoding API + +- **$5.00 por 1.000 requisições** (primeiros 40.000 são gratuitos por mês) +- **$0.005 por requisição** após os 40.000 gratuitos + +> 💡 Para a maioria dos casos de uso, os 40.000 gratuitos são suficientes! + +### OpenStreetMap (Fallback) + +- **100% Gratuito** e ilimitado +- Sem necessidade de configuração +- Precisão levemente menor, mas ainda muito boa + +--- + +## 🔄 Como funciona o sistema + +O sistema foi projetado para usar uma estratégia de **fallback inteligente**: + +1. **Primeiro**: Tenta buscar via Google Maps (se API key configurada) +2. **Se falhar ou não tiver API key**: Usa automaticamente OpenStreetMap +3. **Feedback**: Informa qual serviço foi usado na mensagem de sucesso + +Isso garante que o sistema sempre funcione, mesmo sem a API key do Google Maps. + +--- + +## 🔒 Segurança + +### ⚠️ Importante + +- **Nunca** commite o arquivo `.env` no Git (já está no .gitignore) +- **Nunca** compartilhe sua chave de API publicamente +- Configure **restrições de API** no Google Cloud Console +- Para produção, use variáveis de ambiente seguras no seu provedor de hospedagem + +### Configuração em Produção + +Para ambientes de produção (Vercel, Netlify, etc.): + +1. Acesse as configurações do projeto no seu provedor +2. Vá em **"Environment Variables"** ou **"Variáveis de Ambiente"** +3. Adicione: `VITE_GOOGLE_MAPS_API_KEY` com o valor da sua chave +4. Faça o deploy novamente + +--- + +## ❓ Solução de Problemas + +### A busca não está usando Google Maps + +- Verifique se a variável `VITE_GOOGLE_MAPS_API_KEY` está no arquivo `.env` +- Reinicie o servidor de desenvolvimento +- Verifique no console do navegador se há erros + +### Erro: "This API project is not authorized to use this API" + +- Verifique se a **Geocoding API** está ativada no projeto +- Aguarde alguns minutos após a ativação (pode levar até 5 minutos) + +### Erro: "API key not valid" + +- Verifique se copiou a chave corretamente +- Verifique se as restrições de API permitem o uso da Geocoding API +- Verifique se as restrições de aplicativo permitem seu domínio/endereço + +### Mensagem: "Coordenadas encontradas via OpenStreetMap" + +- Isso é normal se: + - Não há API key configurada + - A API key não é válida + - O Google Maps falhou na busca +- O sistema continua funcionando normalmente com OpenStreetMap + +--- + +## 📚 Recursos Úteis + +- [Google Cloud Console](https://console.cloud.google.com/) +- [Documentação Geocoding API](https://developers.google.com/maps/documentation/geocoding) +- [Preços Google Maps](https://developers.google.com/maps/billing-and-pricing/pricing) +- [OpenStreetMap Nominatim](https://nominatim.org/) + +--- + +## ✅ Resumo + +1. ✅ Crie projeto no Google Cloud +2. ✅ Ative Geocoding API +3. ✅ Crie chave de API +4. ✅ Configure restrições (recomendado) +5. ✅ Adicione `VITE_GOOGLE_MAPS_API_KEY` no `.env` +6. ✅ Reinicie o servidor + +**Pronto!** O sistema agora usará Google Maps para busca de coordenadas com maior precisão. + diff --git a/apps/web/src/lib/components/ponto/RegistroPonto.svelte b/apps/web/src/lib/components/ponto/RegistroPonto.svelte index 801507b..ed3bfb4 100644 --- a/apps/web/src/lib/components/ponto/RegistroPonto.svelte +++ b/apps/web/src/lib/components/ponto/RegistroPonto.svelte @@ -34,6 +34,12 @@ funcionarioId && dataHoje ? { funcionarioId, data: dataHoje } : 'skip' ); + // Query para verificar dispensa ativa + const dispensaQuery = useQuery( + api.pontos.verificarDispensaAtiva, + funcionarioId && dataHoje ? { funcionarioId, data: dataHoje } : 'skip' + ); + // Estados let mostrandoWebcam = $state(false); let registrando = $state(false); @@ -150,6 +156,22 @@ async function registrarPonto() { if (registrando) return; + // 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.'; + mostrarModalErro = true; + return; + } + + // Verificar se está dispensado antes de registrar + if (estaDispensado) { + mensagemErroModal = 'Registro dispensado pelo gestor'; + detalhesErroModal = motivoDispensa || 'Você está dispensado de registrar ponto no momento.'; + mostrarModalErro = true; + return; + } + // Verificar permissões antes de registrar const permissoes = await verificarPermissoes(); if (!permissoes.localizacao || !permissoes.webcam) { @@ -296,6 +318,22 @@ async function iniciarRegistroComFoto() { if (registrando || coletandoInfo) return; + // 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.'; + mostrarModalErro = true; + return; + } + + // Verificar se está dispensado antes de abrir webcam + if (estaDispensado) { + mensagemErroModal = 'Registro dispensado pelo gestor'; + detalhesErroModal = motivoDispensa || 'Você está dispensado de registrar ponto no momento.'; + mostrarModalErro = true; + return; + } + // Verificar permissões antes de abrir webcam const permissoes = await verificarPermissoes(); if (!permissoes.localizacao || !permissoes.webcam) { @@ -542,8 +580,13 @@ } } + const dispensaAtiva = $derived(dispensaQuery?.data); + const estaDispensado = $derived(dispensaAtiva?.dispensado ?? false); + const motivoDispensa = $derived(dispensaAtiva?.motivo ?? null); + const temFuncionarioAssociado = $derived(funcionarioId !== null); + const podeRegistrar = $derived.by(() => { - return !registrando && !coletandoInfo && config !== undefined; + return !registrando && !coletandoInfo && config !== undefined && !estaDispensado && temFuncionarioAssociado; }); // Referência para o modal @@ -650,55 +693,67 @@
- -
-
- -
-
- - -
-
-

- - Horários do Dia -

-
- {#each mapaHorarios as horario (horario.tipo)} -
-
-
- {horario.label} - {#if horario.registrado} - {#if horario.dentroDoPrazo} - - {:else} - - {/if} - {/if} -
-
{horario.horario}
- {#if horario.registrado} -
- Registrado: {horario.horarioRegistrado} -
- {/if} -
-
- {/each} + + {#if !temFuncionarioAssociado} +
+ + + +
+

Funcionário Não Associado

+
+ Você não possui um funcionário associado à sua conta. +
+ Entre em contato com o administrador do sistema para associar um funcionário à sua conta. +
-
+ {/if} + + + {#if estaDispensado && motivoDispensa && temFuncionarioAssociado} +
+ + + +
+

Registro de Ponto Dispensado

+
+ Você está dispensado de registrar ponto no momento. +
+ Motivo: {motivoDispensa} +
+
+
+ {/if}

Registrar Ponto

+
+ +
{#if sucesso}
@@ -730,6 +785,11 @@ class="btn btn-primary btn-lg" onclick={iniciarRegistroComFoto} disabled={!podeRegistrar} + title={!temFuncionarioAssociado + ? 'Você não possui funcionário associado à sua conta' + : estaDispensado + ? 'Você está dispensado de registrar ponto no momento' + : ''} > {#if registrando} @@ -738,6 +798,12 @@ {:else} Registrando... {/if} + {:else if !temFuncionarioAssociado} + + Funcionário Não Associado + {:else if estaDispensado} + + Registro Indisponível {:else if proximoTipo === 'entrada' || proximoTipo === 'retorno_almoco'} Registrar Entrada @@ -750,6 +816,78 @@
+ +
+
+

+ + Horário Padrão +

+ + +
+ {#each mapaHorarios as horario (horario.tipo)} +
+
+ +
+ {#if horario.registrado} + {#if horario.dentroDoPrazo} + + {:else} + + {/if} + {:else} + + {/if} +
+ + +
+ + {horario.label} + +
+ + +
+
+ {horario.horario} +
+
+ + + {#if horario.registrado} +
+
+
+ Registrado: +
+
+ {horario.horarioRegistrado} +
+
+
+ {:else} +
+
+ Aguardando registro +
+
+ {/if} +
+
+ {/each} +
+
+
+ {#if historicoSaldo && registrosOrdenados.length > 0}
@@ -780,57 +918,224 @@
- +
-
-

Registros Realizados

-
- {#each registrosOrdenados as registro (registro._id)} -
-
-
-
-
- - {config - ? getTipoRegistroLabel(registro.tipo, { - nomeEntrada: config.nomeEntrada, - nomeSaidaAlmoco: config.nomeSaidaAlmoco, - nomeRetornoAlmoco: config.nomeRetornoAlmoco, - nomeSaida: config.nomeSaida, - }) - : getTipoRegistroLabel(registro.tipo)} - - {#if registro.dentroDoPrazo} - - {:else} - - {/if} -
-

- {formatarHoraPonto(registro.hora, registro.minuto)} -

- {#if registro.justificativa} -
-

Justificativa:

-

{registro.justificativa}

+
+

Timeline do Dia

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

+ + Entradas +

+
+ + {#each registrosOrdenados.filter(r => r.tipo === 'entrada' || r.tipo === 'retorno_almoco') as registro (registro._id)} +
+ +
+ + +
+
+ +
+ {#if registro.dentroDoPrazo} + + {:else} + + {/if} + + {config + ? getTipoRegistroLabel(registro.tipo, { + nomeEntrada: config.nomeEntrada, + nomeRetornoAlmoco: config.nomeRetornoAlmoco, + }) + : getTipoRegistroLabel(registro.tipo)} +
- {/if} -
-
- + + +

+ {formatarHoraPonto(registro.hora, registro.minuto)} +

+ + + {#if config} + {@const horarioEsperado = registro.tipo === 'entrada' ? config.horarioEntrada : config.horarioRetornoAlmoco} + {@const [horaEsperada, minutoEsperado] = horarioEsperado.split(':').map(Number)} + {@const minutosEsperados = horaEsperada * 60 + minutoEsperado} + {@const minutosRegistrados = registro.hora * 60 + registro.minuto} + {@const diferenca = minutosRegistrados - minutosEsperados} + {@const diferencaAbs = Math.abs(diferenca)} + {@const diferencaTexto = diferencaAbs >= 60 + ? `${Math.floor(diferencaAbs / 60)}h ${diferencaAbs % 60}min` + : `${diferencaAbs}min`} + +
+ Esperado: + {horarioEsperado} + {#if diferencaAbs > 0} + + {diferenca > 0 ? '+' : '-'}{diferencaTexto} + + {/if} +
+ {/if} + + {#if registro.justificativa} +
+

Justificativa:

+

{registro.justificativa}

+
+ {/if} + + +
-
+ {/each} + + + {#if config} + {#each [ + { tipo: 'entrada', horario: config.horarioEntrada, label: config.nomeEntrada || 'Entrada 1' }, + { tipo: 'retorno_almoco', horario: config.horarioRetornoAlmoco, label: config.nomeRetornoAlmoco || 'Entrada 2' } + ] as horarioEsperado} + {#if !registrosOrdenados.find(r => r.tipo === horarioEsperado.tipo)} +
+
+
+
+

{horarioEsperado.label} (não registrado)

+

{horarioEsperado.horario}

+
+
+
+ {/if} + {/each} + {/if}
- {/each} + + +
+
+

+ + Saídas +

+
+ + {#each registrosOrdenados.filter(r => r.tipo === 'saida_almoco' || r.tipo === 'saida') as registro (registro._id)} +
+ +
+ + +
+
+ +
+ + {config + ? getTipoRegistroLabel(registro.tipo, { + nomeSaidaAlmoco: config.nomeSaidaAlmoco, + nomeSaida: config.nomeSaida, + }) + : getTipoRegistroLabel(registro.tipo)} + + {#if registro.dentroDoPrazo} + + {:else} + + {/if} +
+ + +

+ {formatarHoraPonto(registro.hora, registro.minuto)} +

+ + + {#if config} + {@const horarioEsperado = registro.tipo === 'saida_almoco' ? config.horarioSaidaAlmoco : config.horarioSaida} + {@const [horaEsperada, minutoEsperado] = horarioEsperado.split(':').map(Number)} + {@const minutosEsperados = horaEsperada * 60 + minutoEsperado} + {@const minutosRegistrados = registro.hora * 60 + registro.minuto} + {@const diferenca = minutosRegistrados - minutosEsperados} + {@const diferencaAbs = Math.abs(diferenca)} + {@const diferencaTexto = diferencaAbs >= 60 + ? `${Math.floor(diferencaAbs / 60)}h ${diferencaAbs % 60}min` + : `${diferencaAbs}min`} + +
+ {#if diferencaAbs > 0} + + {diferenca > 0 ? '+' : '-'}{diferencaTexto} + + {/if} + {horarioEsperado} + Esperado: +
+ {/if} + + {#if registro.justificativa} +
+

Justificativa:

+

{registro.justificativa}

+
+ {/if} + + +
+
+
+ {/each} + + + {#if config} + {#each [ + { tipo: 'saida_almoco', horario: config.horarioSaidaAlmoco, label: config.nomeSaidaAlmoco || 'Saída 1' }, + { tipo: 'saida', horario: config.horarioSaida, label: config.nomeSaida || 'Saída 2' } + ] as horarioEsperado} + {#if !registrosOrdenados.find(r => r.tipo === horarioEsperado.tipo)} +
+
+
+
+

{horarioEsperado.label} (não registrado)

+

{horarioEsperado.horario}

+
+
+
+ {/if} + {/each} + {/if} +
+
diff --git a/apps/web/src/lib/components/ponto/RelogioSincronizado.svelte b/apps/web/src/lib/components/ponto/RelogioSincronizado.svelte index aaf2e5b..1bb6e23 100644 --- a/apps/web/src/lib/components/ponto/RelogioSincronizado.svelte +++ b/apps/web/src/lib/components/ponto/RelogioSincronizado.svelte @@ -17,36 +17,45 @@ async function atualizarTempo() { try { const config = await client.query(api.configuracaoRelogio.obterConfiguracao, {}); + const gmtOffset = config.gmtOffset ?? 0; + + let timestampBase: number; if (config.usarServidorExterno) { try { const resultado = await client.action(api.configuracaoRelogio.sincronizarTempo, {}); if (resultado.sucesso && resultado.timestamp) { - tempoAtual = new Date(resultado.timestamp); + timestampBase = resultado.timestamp; sincronizado = true; usandoServidorExterno = resultado.usandoServidorExterno || false; offsetSegundos = resultado.offsetSegundos || 0; erro = null; + } else { + throw new Error('Falha ao sincronizar'); } } catch (error) { console.warn('Erro ao sincronizar:', error); if (config.fallbackParaPC) { - tempoAtual = new Date(obterTempoPC()); + timestampBase = obterTempoPC(); sincronizado = false; usandoServidorExterno = false; erro = 'Usando relógio do PC (falha na sincronização)'; } else { - erro = 'Falha ao sincronizar tempo'; + throw error; } } } else { // Usar tempo do servidor Convex - const tempoServidor = await obterTempoServidor(client); - tempoAtual = new Date(tempoServidor); + timestampBase = await obterTempoServidor(client); sincronizado = true; usandoServidorExterno = false; erro = null; } + + // Aplicar GMT offset ao timestamp + // O timestamp está em UTC, adicionar o offset em horas + const timestampAjustado = timestampBase + (gmtOffset * 60 * 60 * 1000); + tempoAtual = new Date(timestampAjustado); } catch (error) { console.error('Erro ao obter tempo:', error); tempoAtual = new Date(obterTempoPC()); diff --git a/apps/web/src/lib/utils/deviceInfo.ts b/apps/web/src/lib/utils/deviceInfo.ts index f0f2c0f..ed3526a 100644 --- a/apps/web/src/lib/utils/deviceInfo.ts +++ b/apps/web/src/lib/utils/deviceInfo.ts @@ -15,6 +15,13 @@ export interface InformacoesDispositivo { latitude?: number; longitude?: number; precisao?: number; + altitude?: number | null; + altitudeAccuracy?: number | null; + heading?: number | null; + speed?: number | null; + confiabilidadeGPS?: number; // 0-1 + suspeitaSpoofing?: boolean; + motivoSuspeita?: string; endereco?: string; cidade?: string; estado?: string; @@ -230,12 +237,289 @@ function obterInformacoesMemoria(): string { } /** - * Obtém localização via GPS com múltiplas tentativas + * Calcula distância entre duas coordenadas (fórmula de Haversine) + * Retorna distância em metros + */ +function calcularDistancia( + lat1: number, + lon1: number, + lat2: number, + lon2: number +): number { + const R = 6371000; // Raio da Terra em metros + const dLat = ((lat2 - lat1) * Math.PI) / 180; + const dLon = ((lon2 - lon1) * Math.PI) / 180; + const a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos((lat1 * Math.PI) / 180) * + Math.cos((lat2 * Math.PI) / 180) * + Math.sin(dLon / 2) * + Math.sin(dLon / 2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return R * c; +} + +/** + * Obtém timezone aproximado por coordenadas + */ +function obterTimezonePorCoordenadas(latitude: number, longitude: number): string { + // Pernambuco está em UTC-3 (America/Recife) + if (longitude >= -45 && longitude <= -30 && latitude >= -10 && latitude <= 5) { + return 'America/Recife'; // UTC-3 + } + + // Fallback: usar timezone do sistema + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone; + } catch { + return 'America/Recife'; // Default + } +} + +/** + * Captura uma única leitura de localização com todas as propriedades disponíveis + */ +async function capturarLocalizacaoUnica( + enableHighAccuracy: boolean = true, + timeout: number = 10000 +): Promise<{ + latitude?: number; + longitude?: number; + precisao?: number; + altitude?: number | null; + altitudeAccuracy?: number | null; + heading?: number | null; + speed?: number | null; + timestamp?: number; + confiabilidade: number; // 0-1 +}> { + return new Promise((resolve) => { + if (typeof navigator === 'undefined' || !navigator.geolocation) { + resolve({ confiabilidade: 0 }); + return; + } + + const timeoutId = setTimeout(() => { + resolve({ confiabilidade: 0 }); + }, timeout + 1000); + + navigator.geolocation.getCurrentPosition( + (position) => { + clearTimeout(timeoutId); + const coords = position.coords; + const { latitude, longitude, accuracy } = coords; + + // Validar coordenadas básicas + if ( + isNaN(latitude) || + isNaN(longitude) || + latitude === 0 || + longitude === 0 || + latitude < -90 || + latitude > 90 || + longitude < -180 || + longitude > 180 + ) { + resolve({ confiabilidade: 0 }); + return; + } + + // Calcular score de confiabilidade baseado em propriedades do GPS real + const sinaisGPSReal = { + temAltitude: coords.altitude !== null && coords.altitude !== 0, + temAltitudeAccuracy: coords.altitudeAccuracy !== null && coords.altitudeAccuracy > 0, + temHeading: coords.heading !== null && !isNaN(coords.heading), + temSpeed: coords.speed !== null && !isNaN(coords.speed), + precisaoBoa: accuracy < 20, // GPS real geralmente < 20m + precisaoMedia: accuracy >= 20 && accuracy < 100, + timestampPreciso: position.timestamp > 0 + }; + + // Calcular confiabilidade: cada sinal adiciona pontos + let pontos = 0; + const maxPontos = 7; + + if (sinaisGPSReal.temAltitude) pontos += 1; + if (sinaisGPSReal.temAltitudeAccuracy) pontos += 1; + if (sinaisGPSReal.temHeading) pontos += 0.5; + if (sinaisGPSReal.temSpeed) pontos += 0.5; + if (sinaisGPSReal.precisaoBoa) pontos += 2; + if (sinaisGPSReal.precisaoMedia) pontos += 1; + if (sinaisGPSReal.timestampPreciso) pontos += 1; + + const confiabilidade = Math.min(pontos / maxPontos, 1); + + resolve({ + latitude, + longitude, + precisao: accuracy, + altitude: coords.altitude ?? null, + altitudeAccuracy: coords.altitudeAccuracy ?? null, + heading: coords.heading ?? null, + speed: coords.speed ?? null, + timestamp: position.timestamp, + confiabilidade + }); + }, + (error) => { + clearTimeout(timeoutId); + console.warn('Erro ao obter localização:', error.code, error.message); + resolve({ confiabilidade: 0 }); + }, + { + enableHighAccuracy, + timeout, + maximumAge: 0 // Sempre obter nova leitura + } + ); + }); +} + +/** + * Obtém localização via GPS com múltiplas leituras para detectar spoofing + * Apps de spoofing geralmente retornam valores idênticos em todas as leituras + */ +async function obterLocalizacaoMultipla(): Promise<{ + latitude?: number; + longitude?: number; + precisao?: number; + altitude?: number | null; + altitudeAccuracy?: number | null; + heading?: number | null; + speed?: number | null; + confiabilidade: number; // 0-1 + suspeitaSpoofing: boolean; + motivoSuspeita?: string; +}> { + if (typeof navigator === 'undefined' || !navigator.geolocation) { + return { confiabilidade: 0, suspeitaSpoofing: true, motivoSuspeita: 'Geolocalização não suportada' }; + } + + // Capturar 3 leituras com intervalo de 2 segundos entre elas + const leituras: Array<{ + lat: number; + lon: number; + precisao: number; + altitude: number | null; + confiabilidade: number; + }> = []; + + for (let i = 0; i < 3; i++) { + const leitura = await capturarLocalizacaoUnica(true, 8000); + + if (leitura.latitude && leitura.longitude && leitura.confiabilidade > 0) { + leituras.push({ + lat: leitura.latitude, + lon: leitura.longitude, + precisao: leitura.precisao || 999, + altitude: leitura.altitude ?? null, + confiabilidade: leitura.confiabilidade + }); + } + + // Aguardar 2 segundos entre leituras (exceto na última) + if (i < 2) { + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + } + + if (leituras.length === 0) { + return { confiabilidade: 0, suspeitaSpoofing: true, motivoSuspeita: 'Não foi possível obter localização' }; + } + + // Se tivermos menos de 2 leituras, usar única leitura com baixa confiança + if (leituras.length < 2) { + const unica = leituras[0]; + return { + latitude: unica.lat, + longitude: unica.lon, + precisao: unica.precisao, + altitude: unica.altitude, + altitudeAccuracy: null, + heading: null, + speed: null, + confiabilidade: unica.confiabilidade * 0.5, // Reduzir confiança por ter apenas 1 leitura + suspeitaSpoofing: true, + motivoSuspeita: 'Apenas uma leitura obtida' + }; + } + + // Verificar se todas as leituras são idênticas (suspeito de spoofing) + const primeiraLeitura = leituras[0]; + const todasIguais = leituras.every( + (l) => + Math.abs(l.lat - primeiraLeitura.lat) < 0.00001 && // ~1 metro + Math.abs(l.lon - primeiraLeitura.lon) < 0.00001 + ); + + if (todasIguais && leituras.length === 3) { + // GPS real varia alguns metros, se todas são idênticas pode ser spoofing + return { + latitude: primeiraLeitura.lat, + longitude: primeiraLeitura.lon, + precisao: primeiraLeitura.precisao, + altitude: primeiraLeitura.altitude, + altitudeAccuracy: null, + heading: null, + speed: null, + confiabilidade: primeiraLeitura.confiabilidade * 0.4, // Reduzir drasticamente confiança + suspeitaSpoofing: true, + motivoSuspeita: 'Todas as leituras são idênticas (GPS real varia alguns metros)' + }; + } + + // Calcular média das leituras e variância + const mediaLat = leituras.reduce((sum, l) => sum + l.lat, 0) / leituras.length; + const mediaLon = leituras.reduce((sum, l) => sum + l.lon, 0) / leituras.length; + const mediaConfianca = leituras.reduce((sum, l) => sum + l.confiabilidade, 0) / leituras.length; + + // Calcular distância máxima entre leituras + let distanciaMaxima = 0; + for (let i = 0; i < leituras.length; i++) { + for (let j = i + 1; j < leituras.length; j++) { + const dist = calcularDistancia( + leituras[i].lat, + leituras[i].lon, + leituras[j].lat, + leituras[j].lon + ); + distanciaMaxima = Math.max(distanciaMaxima, dist); + } + } + + // Se distância máxima for muito grande (> 100m), pode indicar problemas + const suspeitoPorDistancia = distanciaMaxima > 100; + + return { + latitude: mediaLat, + longitude: mediaLon, + precisao: primeiraLeitura.precisao, + altitude: primeiraLeitura.altitude, + altitudeAccuracy: null, + heading: null, + speed: null, + confiabilidade: suspeitoPorDistancia ? mediaConfianca * 0.6 : mediaConfianca, + suspeitaSpoofing: suspeitoPorDistancia, + motivoSuspeita: suspeitoPorDistancia + ? `Variação muito grande entre leituras (${Math.round(distanciaMaxima)}m)` + : undefined + }; +} + +/** + * Obtém localização via GPS com múltiplas tentativas e validações anti-spoofing */ async function obterLocalizacao(): Promise<{ latitude?: number; longitude?: number; precisao?: number; + altitude?: number | null; + altitudeAccuracy?: number | null; + heading?: number | null; + speed?: number | null; + confiabilidadeGPS?: number; + suspeitaSpoofing?: boolean; + motivoSuspeita?: string; endereco?: string; cidade?: string; estado?: string; @@ -246,127 +530,95 @@ async function obterLocalizacao(): Promise<{ return {}; } - // Tentar múltiplas estratégias - const estrategias = [ - // Estratégia 1: Alta precisão (mais lento, mas mais preciso) - { - enableHighAccuracy: true, - timeout: 10000, - maximumAge: 0 - }, - // Estratégia 2: Precisão média (balanceado) - { - enableHighAccuracy: false, - timeout: 8000, - maximumAge: 30000 - }, - // Estratégia 3: Rápido (usa cache) - { - enableHighAccuracy: false, - timeout: 5000, - maximumAge: 60000 - } - ]; + // Usar múltiplas leituras para detectar spoofing + const localizacaoMultipla = await obterLocalizacaoMultipla(); - for (const options of estrategias) { - try { - const resultado = await new Promise<{ - latitude?: number; - longitude?: number; - precisao?: number; - endereco?: string; - cidade?: string; - estado?: string; - pais?: string; - }>((resolve) => { - const timeout = setTimeout(() => { - resolve({}); - }, options.timeout + 1000); + if (!localizacaoMultipla.latitude || !localizacaoMultipla.longitude) { + console.warn('Não foi possível obter localização'); + return { + confiabilidadeGPS: 0, + suspeitaSpoofing: true, + motivoSuspeita: 'Não foi possível obter localização' + }; + } - navigator.geolocation.getCurrentPosition( - async (position) => { - clearTimeout(timeout); - const { latitude, longitude, accuracy } = position.coords; + const { latitude, longitude, precisao, altitude, altitudeAccuracy, heading, speed, confiabilidade, suspeitaSpoofing, motivoSuspeita } = localizacaoMultipla; - // Validar coordenadas - if (isNaN(latitude) || isNaN(longitude) || latitude === 0 || longitude === 0) { - resolve({}); - return; - } + // Tentar obter endereço via reverse geocoding + let endereco = ''; + let cidade = ''; + let estado = ''; + let pais = ''; - // Tentar obter endereço via reverse geocoding - let endereco = ''; - let cidade = ''; - let estado = ''; - let pais = ''; - - try { - const response = await fetch( - `https://nominatim.openstreetmap.org/reverse?format=json&lat=${latitude}&lon=${longitude}&zoom=18&addressdetails=1`, - { - headers: { - 'User-Agent': 'SGSE-App/1.0' - } - } - ); - if (response.ok) { - const data = (await response.json()) as { - address?: { - road?: string; - house_number?: string; - city?: string; - town?: string; - state?: string; - country?: string; - }; - }; - if (data.address) { - const addr = data.address; - if (addr.road) { - endereco = `${addr.road}${addr.house_number ? `, ${addr.house_number}` : ''}`; - } - cidade = addr.city || addr.town || ''; - estado = addr.state || ''; - pais = addr.country || ''; - } - } - } catch (error) { - console.warn('Erro ao obter endereço:', error); - } - - resolve({ - latitude, - longitude, - precisao: accuracy, - endereco, - cidade, - estado, - pais, - }); - }, - (error) => { - clearTimeout(timeout); - console.warn('Erro ao obter localização:', error.code, error.message); - resolve({}); - }, - options - ); - }); - - // Se obteve localização, retornar - if (resultado.latitude && resultado.longitude) { - console.log('Localização obtida com sucesso:', resultado); - return resultado; + try { + const response = await fetch( + `https://nominatim.openstreetmap.org/reverse?format=json&lat=${latitude}&lon=${longitude}&zoom=18&addressdetails=1`, + { + headers: { + 'User-Agent': 'SGSE-App/1.0' + } } - } catch (error) { - console.warn('Erro na estratégia de geolocalização:', error); - continue; + ); + if (response.ok) { + const data = (await response.json()) as { + address?: { + road?: string; + house_number?: string; + city?: string; + town?: string; + state?: string; + country?: string; + }; + }; + if (data.address) { + const addr = data.address; + if (addr.road) { + endereco = `${addr.road}${addr.house_number ? `, ${addr.house_number}` : ''}`; + } + cidade = addr.city || addr.town || ''; + estado = addr.state || ''; + pais = addr.country || ''; + } + } + } catch (error) { + console.warn('Erro ao obter endereço:', error); + } + + // Validar timezone vs localização + if (typeof navigator !== 'undefined') { + const timezoneAtual = Intl.DateTimeFormat().resolvedOptions().timeZone; + const timezoneEsperado = obterTimezonePorCoordenadas(latitude, longitude); + + // Se timezone é muito diferente, pode ser suspeito + if (timezoneAtual !== timezoneEsperado && timezoneAtual !== 'America/Recife' && timezoneEsperado !== 'America/Recife') { + console.warn(`Timezone inconsistente: esperado ${timezoneEsperado}, atual ${timezoneAtual}`); } } - // Se todas as estratégias falharam, retornar vazio - console.warn('Não foi possível obter localização após todas as tentativas'); - return {}; + console.log('Localização obtida com validações:', { + latitude, + longitude, + confiabilidade: confiabilidade.toFixed(2), + suspeitaSpoofing, + motivoSuspeita + }); + + return { + latitude, + longitude, + precisao, + altitude, + altitudeAccuracy, + heading, + speed, + confiabilidadeGPS: confiabilidade, + suspeitaSpoofing: suspeitaSpoofing || false, + motivoSuspeita, + endereco, + cidade, + estado, + pais + }; } /** @@ -439,6 +691,13 @@ export async function obterInformacoesDispositivo(): Promise - import { onMount } from 'svelte'; import { useQuery, useConvexClient } from 'convex-svelte'; import { api } from '@sgse-app/backend/convex/_generated/api'; - import { Clock, Plus, X, Trash2 } from 'lucide-svelte'; + import { Clock, Plus, X, Trash2, AlertTriangle } from 'lucide-svelte'; import type { Id } from '@sgse-app/backend/convex/_generated/dataModel'; import { toast } from 'svelte-sonner'; @@ -11,21 +10,32 @@ // Estados let funcionariosSelecionados = $state[]>([]); let modoCriacao = $state(false); + let mostrandoModalExcluir = $state(false); + let dispensaParaExcluir = $state | null>(null); // Formulário let dataInicio = $state(new Date().toISOString().split('T')[0]!); - let horaInicio = $state(8); - let minutoInicio = $state(0); + let horaInicioTime = $state('08:00'); let dataFim = $state(new Date().toISOString().split('T')[0]!); - let horaFim = $state(18); - let minutoFim = $state(0); + let horaFimTime = $state('18:00'); let motivo = $state(''); let isento = $state(false); + // Computed para converter time string para hora/minuto + const horaInicio = $derived.by(() => { + const [hora, minuto] = horaInicioTime.split(':').map(Number); + return { hora: hora || 8, minuto: minuto || 0 }; + }); + + const horaFim = $derived.by(() => { + const [hora, minuto] = horaFimTime.split(':').map(Number); + return { hora: hora || 18, minuto: minuto || 0 }; + }); + // Queries const subordinadosQuery = useQuery(api.times.listarSubordinadosDoGestorAtual, {}); const dispensasQuery = useQuery(api.pontos.listarDispensas, { - apenasAtivas: false, // Mostrar todas para o gestor ver histórico + apenasAtivas: true, // Mostrar apenas dispensas ativas }); const subordinados = $derived(subordinadosQuery?.data || []); @@ -52,11 +62,9 @@ modoCriacao = true; funcionariosSelecionados = []; dataInicio = new Date().toISOString().split('T')[0]!; - horaInicio = 8; - minutoInicio = 0; + horaInicioTime = '08:00'; dataFim = new Date().toISOString().split('T')[0]!; - horaFim = 18; - minutoFim = 0; + horaFimTime = '18:00'; motivo = ''; isento = false; } @@ -99,11 +107,11 @@ client.mutation(api.pontos.criarDispensaRegistro, { funcionarioId, dataInicio, - horaInicio, - minutoInicio, + horaInicio: horaInicio.hora, + minutoInicio: horaInicio.minuto, dataFim, - horaFim, - minutoFim, + horaFim: horaFim.hora, + minutoFim: horaFim.minuto, motivo, isento, }) @@ -121,15 +129,26 @@ } } - async function removerDispensa(dispensaId: Id<'dispensasRegistro'>) { - if (!confirm('Deseja realmente remover esta dispensa?')) return; + function abrirModalExcluir(dispensaId: Id<'dispensasRegistro'>) { + dispensaParaExcluir = dispensaId; + mostrandoModalExcluir = true; + } + + function fecharModalExcluir() { + mostrandoModalExcluir = false; + dispensaParaExcluir = null; + } + + async function confirmarRemoverDispensa() { + if (!dispensaParaExcluir) return; try { await client.mutation(api.pontos.removerDispensaRegistro, { - dispensaId, + dispensaId: dispensaParaExcluir, }); toast.success('Dispensa removida com sucesso'); + fecharModalExcluir(); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); toast.error(`Erro ao remover dispensa: ${errorMessage}`); @@ -164,19 +183,27 @@ {#if modoCriacao}
-
-

Criar Dispensa de Registro

+
+

Criar Dispensa de Registro

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

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

+
-
- @@ -342,7 +353,7 @@
+ + + {#if mostrandoModalExcluir && dispensaParaExcluir} + + + + + {/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 index 061f56f..dafe028 100644 --- 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 @@ -2,7 +2,7 @@ import { onMount } from 'svelte'; import { useQuery, useConvexClient } from 'convex-svelte'; import { api } from '@sgse-app/backend/convex/_generated/api'; - import { Clock, Edit, TrendingUp, TrendingDown, Save, X } from 'lucide-svelte'; + import { Clock, Edit, TrendingUp, TrendingDown, Save, X, Trash2, Eye, MoreVertical } from 'lucide-svelte'; import type { Id } from '@sgse-app/backend/convex/_generated/dataModel'; import { formatarHoraPonto, getTipoRegistroLabel } from '$lib/utils/ponto'; import { toast } from 'svelte-sonner'; @@ -14,6 +14,15 @@ let registroSelecionado = $state | ''>(''); let modoEdicao = $state(false); let abaAtiva = $state<'editar' | 'ajustar'>('editar'); + let homologacaoDetalhada = $state | null>(null); + let homologacaoParaExcluir = $state | null>(null); + let mostrandoModalDetalhes = $state(false); + let mostrandoModalExcluir = $state(false); + + // Monitorar mudanças em funcionarioSelecionado + $effect(() => { + console.log('🔄 [DEBUG] funcionarioSelecionado mudou:', funcionarioSelecionado, typeof funcionarioSelecionado); + }); // Formulário de edição let horaNova = $state(8); @@ -98,19 +107,46 @@ const homologacoesParams = $derived({ funcionarioId: funcionarioSelecionado || undefined, }); - const registrosQueryParams = $derived({ - funcionarioId: funcionarioSelecionado || undefined, - dataInicio: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]!, - dataFim: new Date().toISOString().split('T')[0]!, + + // Parâmetros para query de registros - só executa quando há funcionário selecionado + const registrosQueryParams = $derived.by(() => { + // Verificar se funcionarioSelecionado não é string vazia + if (!funcionarioSelecionado || funcionarioSelecionado === '') { + return undefined; + } + return { + funcionarioId: funcionarioSelecionado as Id<'funcionarios'>, + dataInicio: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]!, + dataFim: new Date().toISOString().split('T')[0]!, + }; }); const homologacoesQuery = useQuery(api.pontos.listarHomologacoes, homologacoesParams); - const registrosQuery = useQuery(api.pontos.listarRegistrosPeriodo, registrosQueryParams); + const registrosQuery = $derived( + registrosQueryParams + ? useQuery(api.pontos.listarRegistrosPeriodo, registrosQueryParams) + : null + ); const subordinados = $derived(subordinadosQuery?.data || []); const motivos = $derived(motivosQuery?.data); const homologacoes = $derived(homologacoesQuery?.data || []); - const registros = $derived(registrosQuery?.data || []); + + // Registros já filtrados pela query no backend + const registros = $derived.by(() => { + if (!funcionarioSelecionado || funcionarioSelecionado === '' || !registrosQuery) { + return []; + } + const dados = registrosQuery.data; + if (!dados || !Array.isArray(dados)) { + return []; + } + // A query do backend já filtra pelo funcionário, mas adicionamos verificação extra + return dados.filter((r) => String(r.funcionarioId) === String(funcionarioSelecionado)); + }); + + // Verificar se é gestor (tem subordinados) + const isGestor = $derived(subordinados.length > 0); // Lista de funcionários do time const funcionarios = $derived.by(() => { @@ -142,7 +178,7 @@ motivoDescricao = ''; observacoes = ''; modoEdicao = true; - modoAjuste = false; + abaAtiva = 'editar'; } function abrirEdicaoComAjuste(registroId: Id<'registrosPonto'>) { @@ -253,6 +289,61 @@ toast.error(`Erro ao ajustar banco de horas: ${errorMessage}`); } } + + function abrirDetalhes(homologacaoId: Id<'homologacoesPonto'>) { + homologacaoDetalhada = homologacaoId; + mostrandoModalDetalhes = true; + } + + function fecharDetalhes() { + mostrandoModalDetalhes = false; + homologacaoDetalhada = null; + } + + function abrirModalExcluir(homologacaoId: Id<'homologacoesPonto'>) { + homologacaoParaExcluir = homologacaoId; + mostrandoModalExcluir = true; + } + + function fecharModalExcluir() { + mostrandoModalExcluir = false; + homologacaoParaExcluir = null; + } + + async function excluirHomologacao() { + if (!homologacaoParaExcluir) return; + + try { + await client.mutation(api.pontos.excluirHomologacao, { + homologacaoId: homologacaoParaExcluir, + }); + + toast.success('Homologação excluída com sucesso'); + fecharModalExcluir(); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + toast.error(`Erro ao excluir homologação: ${errorMessage}`); + } + } + + function editarHomologacao(homologacaoId: Id<'homologacoesPonto'>) { + const homologacao = homologacoes.find((h) => h._id === homologacaoId); + if (!homologacao) return; + + // Se for edição de registro, abrir edição do registro + if (homologacao.registroId) { + funcionarioSelecionado = homologacao.funcionarioId; + abrirEdicaoComAjuste(homologacao.registroId); + } else { + // Se for ajuste de banco de horas, não há como editar diretamente + toast.info('Ajustes de banco de horas não podem ser editados. Crie um novo ajuste para corrigir.'); + } + } + + const homologacaoSelecionada = $derived.by(() => { + if (!homologacaoDetalhada) return null; + return homologacoes.find((h) => h._id === homologacaoDetalhada) || null; + });
@@ -273,14 +364,14 @@

Selecionar Funcionário

- {#each funcionarios as funcionario} - {/each} @@ -291,33 +382,40 @@ {#if modoEdicao && registroSelecionado}
-
- -
-
-

- - Homologar Registro de Ponto -

- {#if dataRegistroFormatada} -

- Registro do dia {dataRegistroFormatada} -

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

+ Homologar Registro de Ponto +

+ {#if dataRegistroFormatada} +
+ + + Registro do dia {dataRegistroFormatada} + +
+ {/if} +
- -
+ +
-
@@ -402,172 +520,201 @@ {#if abaAtiva === 'ajustar'} -
- -
- - +
+ +
+
+
+
+ +
+
+

Tipo de Ajuste

+

Selecione o tipo de ajuste a ser aplicado

+
+
+
+ +
+
- -
- - - -
-

-
- Início do Período -

-
-
- - -
- -
- - -
-
-
- - -
- -
- - -
-

-
- Fim do Período -

-
-
- - -
- -
- - -
-
-
- - - {#if dataInicioAjuste && horaInicioAjuste && dataFimAjuste && horaFimAjuste} - {@const periodoCalculado = calcularPeriodo(dataInicioAjuste, horaInicioAjuste, dataFimAjuste, horaFimAjuste)} - {#if periodoCalculado.dias > 0 || periodoCalculado.horas > 0 || periodoCalculado.minutos > 0} -
-
Período Calculado:
-
- {periodoCalculado.dias > 0 ? `${periodoCalculado.dias} dia${periodoCalculado.dias > 1 ? 's' : ''} ` : ''} - {periodoCalculado.horas > 0 ? `${periodoCalculado.horas} hora${periodoCalculado.horas > 1 ? 's' : ''} ` : ''} - {periodoCalculado.minutos > 0 ? `${periodoCalculado.minutos} minuto${periodoCalculado.minutos > 1 ? 's' : ''}` : ''} - {#if periodoCalculado.dias === 0 && periodoCalculado.horas === 0 && periodoCalculado.minutos === 0} - Período inválido - {/if} + +
+
+
+
+
+ +
+
+

Período do Ajuste

+

Defina o período de início e fim do ajuste

- {/if} - {/if} -
+ Obrigatório +
- -
-
- - +
+ +
+ + +
+
+
+ + +
+ +
+ + +
+

+
+ Fim do Período +

+
+
+ + +
+ +
+ + +
+
+
+ + + {#if dataInicioAjuste && horaInicioAjuste && dataFimAjuste && horaFimAjuste} + {@const periodoCalculado = calcularPeriodo(dataInicioAjuste, horaInicioAjuste, dataFimAjuste, horaFimAjuste)} + {#if periodoCalculado.dias > 0 || periodoCalculado.horas > 0 || periodoCalculado.minutos > 0} +
+
+ +
Período Calculado
+
+
+ {periodoCalculado.dias > 0 ? `${periodoCalculado.dias} dia${periodoCalculado.dias > 1 ? 's' : ''} ` : ''} + {periodoCalculado.horas > 0 ? `${periodoCalculado.horas} hora${periodoCalculado.horas > 1 ? 's' : ''} ` : ''} + {periodoCalculado.minutos > 0 ? `${periodoCalculado.minutos} minuto${periodoCalculado.minutos > 1 ? 's' : ''}` : ''} +
+
{/if} - -
- -
- - + {/if}
- -
- - + +
+
+

+
+ Motivo e Justificativa +

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

+
+ Observações Adicionais +

+
+ +
+
-
- -
@@ -578,55 +725,61 @@ {/if} - {#if funcionarioSelecionado && !modoEdicao} + {#if funcionarioSelecionado}

Registros do Funcionário

- {#if registros.length === 0} + {#if registrosQuery?.status === 'Loading'} +
+ +
+ {:else if registros.length === 0}
Nenhum registro encontrado
{:else}
- - - - - - - - - - - - {#each registros as registro} +
+
DataTipoHorárioStatusAções
+ - - - - - + + + + + - {/each} - -
{registro.data} - {getTipoRegistroLabel(registro.tipo)} - {formatarHoraPonto(registro.hora, registro.minuto)} - - {registro.dentroDoPrazo ? 'Dentro do Prazo' : 'Fora do Prazo'} - - - - DataTipoHorárioStatusAções
+ + + {#each registros as registro} + + {registro.data} + + {getTipoRegistroLabel(registro.tipo)} + + {formatarHoraPonto(registro.hora, registro.minuto)} + + + {registro.dentroDoPrazo ? 'Dentro do Prazo' : 'Fora do Prazo'} + + + + + + + {/each} + + +
{/if}
@@ -634,27 +787,30 @@ {/if} - {#if !modoEdicao} -
-
-

- 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 -
+
+
+

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

+ + {#if homologacoesQuery?.status === 'Loading'} +
+ +
+ {:else if homologacoes.length === 0} +
+ Nenhuma homologação encontrada +
+ {:else}
@@ -667,6 +823,9 @@ + {#if isGestor} + + {/if} @@ -723,6 +882,35 @@ {homologacao.observacoes || '-'} + {#if isGestor} + + {/if} {/each} @@ -731,6 +919,167 @@ {/if} + + + {#if mostrandoModalDetalhes && homologacaoSelecionada} + + {/if} + + + {#if mostrandoModalExcluir && homologacaoParaExcluir} + {/if} diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/+page.svelte index 28d9449..515fd6a 100644 --- a/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/+page.svelte +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/+page.svelte @@ -65,9 +65,9 @@ $: needsScroll = filtered.length > 8; -
+
-
Detalhes Motivo ObservaçõesAções
+
+ + {#if homologacao.registroId} + + {/if} + +
+
- - - - - - - - - - - - - {#each filtered as f} - - - - - - - - + + {/each} + {/if} + +
NomeCPFMatrículaTipoCidadeUFAções
{f.nome}{f.cpf}{f.matricula}{f.simboloTipo}{f.cidade}{f.uf} - +
+
-
- -
- Exibindo {filtered.length} de {list.length} funcionário(s) + +
+ Exibindo {filtered.length} de {list.length} funcionário(s) +
diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/[funcionarioId]/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/[funcionarioId]/+page.svelte index 302548d..b2d838e 100644 --- a/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/[funcionarioId]/+page.svelte +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/[funcionarioId]/+page.svelte @@ -15,6 +15,7 @@ APOSENTADO_OPTIONS, } from "$lib/utils/constants"; import PrintModal from "$lib/components/PrintModal.svelte"; + import { MapPin } from "lucide-svelte"; const client = useConvexClient(); @@ -203,6 +204,16 @@ Imprimir Ficha +
diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/[funcionarioId]/enderecos-marcacao/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/[funcionarioId]/enderecos-marcacao/+page.svelte new file mode 100644 index 0000000..955a565 --- /dev/null +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/[funcionarioId]/enderecos-marcacao/+page.svelte @@ -0,0 +1,497 @@ + + +
+ + + + +
+
+
+ +
+
+

Endereços de Marcação

+

+ {#if funcionario} + Gerenciar locais permitidos para registro de ponto de {funcionario.nome} + {:else} + Carregando... + {/if} +

+
+
+ {#if enderecosParaAssociar.length > 0} + + {/if} +
+ + + {#if mostrarModalAssociacao} + + {/if} + + +
+
+
+
+ + +
+
+
+
+ + + {#if associacoes.length === 0} +
+ + + + + Este funcionário não possui endereços específicos associados. O sistema usará + automaticamente os endereços tipo "Sede Principal" configurados globalmente. + +
+ {/if} + + +
+ {#if associacoesFiltradas.length === 0} +
+
+ +

+ {termoBusca + ? 'Nenhuma associação encontrada' + : 'Nenhum endereço associado'} +

+
+
+ {:else} + {#each associacoesFiltradas as associacao (associacao._id)} + {@const raioUsado = associacao.raioMetros} +
+
+
+
+
+

{associacao.endereco.nome}

+ + {associacao.ativo ? 'Ativa' : 'Inativa'} + + + {tiposLabel[associacao.endereco.tipo] || associacao.endereco.tipo} + +
+
+

+ Endereço: {associacao.endereco.endereco} +

+

+ Cidade: {associacao.endereco.cidade}/ + {associacao.endereco.estado} +

+

+ Raio Permitido: + {#if raioUsado >= 1000} + {@const raioKm = (raioUsado / 1000).toFixed(2)} + {raioKm} km + {:else} + {raioUsado} metros + {/if} + {#if associacao.raioMetrosPersonalizado !== null && + associacao.raioMetrosPersonalizado !== undefined} + + Personalizado + + {:else} + + Padrão ({associacao.endereco.raioMetros}m) + + {/if} +

+ {#if associacao.dataInicio || associacao.dataFim} +

+ Período: + {#if associacao.dataInicio} + {@const dataInicioFormatada = new Date(associacao.dataInicio + 'T00:00:00').toLocaleDateString('pt-BR')} + {dataInicioFormatada} + {:else} + ... + {/if} + até + {#if associacao.dataFim} + {@const dataFimFormatada = new Date(associacao.dataFim + 'T00:00:00').toLocaleDateString('pt-BR')} + {dataFimFormatada} + {:else} + ... + {/if} +

+ {/if} +
+
+ +
+ + +
+
+
+
+ {/each} + {/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 521766a..04c223e 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 @@ -1,5 +1,5 @@ -
+
-
-
-
- +
+
+
+
+
+
+ +
+
+

+ Registro de Pontos +

+

+ Gerencie e visualize os registros de ponto dos funcionários com informações detalhadas e relatórios +

+
-
-

Registro de Pontos

-

Gerencie e visualize os registros de ponto dos funcionários

+ {#if estatisticas} +
+
+

Total de Registros

+

{estatisticas.totalRegistros}

+
+
+

Funcionários

+

{estatisticas.totalFuncionarios}

+
+
+
+ + {estatisticas.totalRegistros > 0 + ? ((estatisticas.dentroDoPrazo / estatisticas.totalRegistros) * 100).toFixed(1) + : 0}% dentro do prazo + + Ativo +
+
+ {/if} +
+
+ + + {#if estatisticas} +
+ +
+
+
+
+

Total de Registros

+

{estatisticas.totalRegistros}

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

Dentro do Prazo

+

{estatisticas.dentroDoPrazo}

+

+ {estatisticas.totalRegistros > 0 + ? ((estatisticas.dentroDoPrazo / estatisticas.totalRegistros) * 100).toFixed(1) + : 0}% do total +

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

Fora do Prazo

+

{estatisticas.foraDoPrazo}

+

+ {estatisticas.totalRegistros > 0 + ? ((estatisticas.foraDoPrazo / estatisticas.totalRegistros) * 100).toFixed(1) + : 0}% do total +

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

Funcionários

+

{estatisticas.totalFuncionarios}

+

+ {estatisticas.funcionariosDentroPrazo} dentro, {estatisticas.funcionariosForaPrazo} fora +

+
+
+ +
+
+
-
+ {/if} + + + {#if estatisticas} +
+
+
+

+
+ +
+ Visão Geral das Estatísticas +

+
+
+ + {#if !chartInstance && estatisticas} +
+ +
+ {/if} +
+
+
+ {/if} -
+
-

- - Filtros -

-
+
+
+ +
+

Filtros de Busca

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

+ 2 + Endereço Físico +

+
+
+ +
+ +
+ + +
+
+ +
+ + +
+ +
+
+ +
+ { + const cepLimpo = onlyDigits(cep); + if (cepLimpo.length === 8) { + buscarEnderecoPorCEP(); + } + }} + placeholder="00000-000" + maxlength="9" + class="input input-bordered focus:input-primary focus:ring-2 focus:ring-primary/20 w-full pr-10" + /> + {#if buscandoCEP} +
+ +
+ {/if} +
+
+ + {#if buscandoCEP} + Buscando endereço... + {:else if cep} + Digite o CEP completo para buscar automaticamente + {/if} + +
+
+ +
+ + +
+ +
+ + +
+
+ +
+ + +
+
+ + +
+
+
+

+ 3 + Localização GPS e Configuração +

+
+
+ +
+
+
+ +
+
+

Coordenadas GPS

+

+ As coordenadas são usadas para validar a localização dos registros de ponto. O sistema busca automaticamente via Google Maps (se configurado) ou OpenStreetMap. +

+
+
+
+ +
+
+ +
+ + {#if buscandoCoordenadas} +
+ +
+ {/if} +
+
+ Formato: -8.047600 +
+
+ +
+ +
+ + {#if buscandoCoordenadas} +
+ +
+ {/if} +
+
+ Formato: -34.877000 +
+
+ +
+ + +
+ + Em metros. Ex: 100, 500, 1000 + +
+
+
+
+
+ + + +
+
+ {/if} + + +
+
+
+
+ + +
+
+
+
+ + +
+ {#if enderecosFiltrados.length === 0} +
+
+ +

+ {termoBusca ? 'Nenhum endereço encontrado' : 'Nenhum endereço cadastrado'} +

+
+
+ {:else} + {#each enderecosFiltrados as enderecoItem (enderecoItem._id)} +
+
+
+
+
+

{enderecoItem.nome}

+ + {enderecoItem.ativo ? 'Ativo' : 'Inativo'} + + + {tiposLabel[enderecoItem.tipo]} + +
+ + {#if enderecoItem.descricao} +

{enderecoItem.descricao}

+ {/if} + +
+

+ Endereço: {enderecoItem.endereco} + {#if (enderecoItem as any).bairro} + - Bairro: {(enderecoItem as any).bairro} + {/if} +

+

+ Cidade: {enderecoItem.cidade}/{enderecoItem.estado} + {#if enderecoItem.cep} + - CEP: {enderecoItem.cep} + {/if} +

+

+ Coordenadas: {enderecoItem.latitude.toFixed(6)}, {enderecoItem.longitude.toFixed(6)} +

+

+ Raio Permitido: + {#if enderecoItem.raioMetros >= 1000} + {@const raioKm = (enderecoItem.raioMetros / 1000).toFixed(2)} + {raioKm} km + {:else} + {enderecoItem.raioMetros} metros + {/if} +

+
+
+ +
+ + {#if enderecoItem.ativo} + + {:else} + + {/if} +
+
+
+
+ {/each} + {/if} +
+
+ diff --git a/packages/backend/convex/_generated/api.d.ts b/packages/backend/convex/_generated/api.d.ts index ac18618..14944ae 100644 --- a/packages/backend/convex/_generated/api.d.ts +++ b/packages/backend/convex/_generated/api.d.ts @@ -29,8 +29,10 @@ import type * as cursos from "../cursos.js"; import type * as dashboard from "../dashboard.js"; import type * as documentos from "../documentos.js"; import type * as email from "../email.js"; +import type * as enderecosMarcacao from "../enderecosMarcacao.js"; import type * as empresas from "../empresas.js"; import type * as ferias from "../ferias.js"; +import type * as funcionarioEnderecos from "../funcionarioEnderecos.js"; import type * as funcionarios from "../funcionarios.js"; import type * as healthCheck from "../healthCheck.js"; import type * as http from "../http.js"; @@ -82,8 +84,10 @@ declare const fullApi: ApiFromModules<{ dashboard: typeof dashboard; documentos: typeof documentos; email: typeof email; + enderecosMarcacao: typeof enderecosMarcacao; empresas: typeof empresas; ferias: typeof ferias; + funcionarioEnderecos: typeof funcionarioEnderecos; funcionarios: typeof funcionarios; healthCheck: typeof healthCheck; http: typeof http; diff --git a/packages/backend/convex/configuracaoPonto.ts b/packages/backend/convex/configuracaoPonto.ts index a495a09..8941202 100644 --- a/packages/backend/convex/configuracaoPonto.ts +++ b/packages/backend/convex/configuracaoPonto.ts @@ -34,17 +34,21 @@ export const obterConfiguracao = query({ nomeSaidaAlmoco: 'Saída 1', nomeRetornoAlmoco: 'Entrada 2', nomeSaida: 'Saída 2', + validarLocalizacao: true, + toleranciaDistanciaMetros: 100, ativo: false, }; } - // Garantir que os nomes padrão estejam definidos + // Garantir que os nomes padrão e valores padrão estejam definidos return { ...config, nomeEntrada: config.nomeEntrada || 'Entrada 1', nomeSaidaAlmoco: config.nomeSaidaAlmoco || 'Saída 1', nomeRetornoAlmoco: config.nomeRetornoAlmoco || 'Entrada 2', nomeSaida: config.nomeSaida || 'Saída 2', + validarLocalizacao: config.validarLocalizacao ?? true, + toleranciaDistanciaMetros: config.toleranciaDistanciaMetros ?? 100, }; }, }); @@ -63,6 +67,8 @@ export const salvarConfiguracao = mutation({ nomeSaidaAlmoco: v.optional(v.string()), nomeRetornoAlmoco: v.optional(v.string()), nomeSaida: v.optional(v.string()), + validarLocalizacao: v.optional(v.boolean()), + toleranciaDistanciaMetros: v.optional(v.number()), }, handler: async (ctx, args) => { const usuario = await getCurrentUserFunction(ctx); @@ -113,6 +119,13 @@ export const salvarConfiguracao = mutation({ throw new Error('Horário de retorno do almoço deve ser anterior à saída'); } + // Validar tolerância de distância se fornecida + if (args.toleranciaDistanciaMetros !== undefined) { + if (args.toleranciaDistanciaMetros < 0 || args.toleranciaDistanciaMetros > 50000) { + throw new Error('Tolerância de distância deve estar entre 0 e 50000 metros'); + } + } + // Desativar configurações antigas const configsAntigas = await ctx.db .query('configuracaoPonto') @@ -134,6 +147,8 @@ export const salvarConfiguracao = mutation({ nomeSaidaAlmoco: args.nomeSaidaAlmoco || 'Saída 1', nomeRetornoAlmoco: args.nomeRetornoAlmoco || 'Entrada 2', nomeSaida: args.nomeSaida || 'Saída 2', + validarLocalizacao: args.validarLocalizacao ?? true, + toleranciaDistanciaMetros: args.toleranciaDistanciaMetros ?? 100, ativo: true, atualizadoPor: usuario._id as Id<'usuarios'>, atualizadoEm: Date.now(), diff --git a/packages/backend/convex/enderecosMarcacao.ts b/packages/backend/convex/enderecosMarcacao.ts new file mode 100644 index 0000000..fdcec94 --- /dev/null +++ b/packages/backend/convex/enderecosMarcacao.ts @@ -0,0 +1,627 @@ +import { v } from 'convex/values'; +import { mutation, query } from './_generated/server'; +import { getCurrentUserFunction } from './auth'; +import type { Id } from './_generated/dataModel'; +import type { MutationCtx } from './_generated/server'; + +/** + * Calcula distância entre duas coordenadas (fórmula de Haversine) + * Retorna distância em metros + */ +function calcularDistancia( + lat1: number, + lon1: number, + lat2: number, + lon2: number +): number { + const R = 6371000; // Raio da Terra em metros + const dLat = ((lat2 - lat1) * Math.PI) / 180; + const dLon = ((lon2 - lon1) * Math.PI) / 180; + const a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos((lat1 * Math.PI) / 180) * + Math.cos((lat2 * Math.PI) / 180) * + Math.sin(dLon / 2) * + Math.sin(dLon / 2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return R * c; +} + +/** + * Lista todos os endereços de marcação ativos + */ +export const listarEnderecos = query({ + args: { + incluirInativos: v.optional(v.boolean()), + }, + handler: async (ctx, args) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) { + throw new Error('Usuário não autenticado'); + } + + let enderecos; + if (args.incluirInativos) { + enderecos = await ctx.db.query('enderecosMarcacao').collect(); + } else { + enderecos = await ctx.db + .query('enderecosMarcacao') + .withIndex('by_ativo', (q) => q.eq('ativo', true)) + .collect(); + } + + // Ordenar por nome + return enderecos.sort((a, b) => a.nome.localeCompare(b.nome)); + }, +}); + +/** + * Obtém um endereço específico + */ +export const obterEndereco = query({ + args: { + enderecoId: v.id('enderecosMarcacao'), + }, + handler: async (ctx, args) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) { + throw new Error('Usuário não autenticado'); + } + + const endereco = await ctx.db.get(args.enderecoId); + if (!endereco) { + throw new Error('Endereço não encontrado'); + } + + return endereco; + }, +}); + +/** + * Cria um novo endereço de marcação + */ +export const criarEndereco = mutation({ + args: { + nome: v.string(), + descricao: v.optional(v.string()), + latitude: v.number(), + longitude: v.number(), + endereco: v.string(), + bairro: v.optional(v.string()), + cep: v.optional(v.string()), + cidade: v.string(), + estado: v.string(), + pais: v.optional(v.string()), + raioMetros: v.number(), + tipo: v.union( + v.literal('sede'), + v.literal('home_office'), + v.literal('deslocamento'), + v.literal('cliente') + ), + }, + handler: async (ctx, args) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) { + throw new Error('Usuário não autenticado'); + } + + // TODO: Verificar permissões (apenas TI ou admin) + + // Validações + if (!args.nome || args.nome.trim().length === 0) { + throw new Error('Nome é obrigatório'); + } + + if ( + isNaN(args.latitude) || + args.latitude < -90 || + args.latitude > 90 || + isNaN(args.longitude) || + args.longitude < -180 || + args.longitude > 180 + ) { + throw new Error('Coordenadas inválidas'); + } + + if (args.raioMetros < 0 || args.raioMetros > 50000) { + throw new Error('Raio deve estar entre 0 e 50000 metros'); + } + + if (!args.endereco || args.endereco.trim().length === 0) { + throw new Error('Endereço é obrigatório'); + } + + if (!args.cidade || args.cidade.trim().length === 0) { + throw new Error('Cidade é obrigatória'); + } + + if (!args.estado || args.estado.trim().length === 0) { + throw new Error('Estado é obrigatório'); + } + + const enderecoId = await ctx.db.insert('enderecosMarcacao', { + nome: args.nome.trim(), + descricao: args.descricao?.trim(), + latitude: args.latitude, + longitude: args.longitude, + endereco: args.endereco.trim(), + bairro: args.bairro?.trim(), + cep: args.cep?.trim(), + cidade: args.cidade.trim(), + estado: args.estado.trim(), + pais: args.pais?.trim() || 'Brasil', + raioMetros: args.raioMetros, + tipo: args.tipo, + ativo: true, + criadoPor: usuario._id as Id<'usuarios'>, + criadoEm: Date.now(), + }); + + return { enderecoId }; + }, +}); + +/** + * Atualiza um endereço de marcação + */ +export const atualizarEndereco = mutation({ + args: { + enderecoId: v.id('enderecosMarcacao'), + nome: v.optional(v.string()), + descricao: v.optional(v.string()), + latitude: v.optional(v.number()), + longitude: v.optional(v.number()), + endereco: v.optional(v.string()), + bairro: v.optional(v.string()), + cep: v.optional(v.string()), + cidade: v.optional(v.string()), + estado: v.optional(v.string()), + pais: v.optional(v.string()), + raioMetros: v.optional(v.number()), + ativo: v.optional(v.boolean()), + tipo: v.optional( + v.union( + v.literal('sede'), + v.literal('home_office'), + v.literal('deslocamento'), + v.literal('cliente') + ) + ), + }, + handler: async (ctx, args) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) { + throw new Error('Usuário não autenticado'); + } + + // TODO: Verificar permissões (apenas TI ou admin) + + const endereco = await ctx.db.get(args.enderecoId); + if (!endereco) { + throw new Error('Endereço não encontrado'); + } + + const atualizacoes: { + nome?: string; + descricao?: string; + latitude?: number; + longitude?: number; + endereco?: string; + bairro?: string; + cep?: string; + cidade?: string; + estado?: string; + pais?: string; + raioMetros?: number; + tipo?: 'sede' | 'home_office' | 'deslocamento' | 'cliente'; + ativo?: boolean; + atualizadoPor?: Id<'usuarios'>; + atualizadoEm?: number; + } = {}; + + if (args.nome !== undefined) { + if (args.nome.trim().length === 0) { + throw new Error('Nome não pode ser vazio'); + } + atualizacoes.nome = args.nome.trim(); + } + + if (args.descricao !== undefined) { + atualizacoes.descricao = args.descricao?.trim() || undefined; + } + + if (args.latitude !== undefined || args.longitude !== undefined) { + const lat = args.latitude ?? endereco.latitude; + const lon = args.longitude ?? endereco.longitude; + + if ( + isNaN(lat) || + lat < -90 || + lat > 90 || + isNaN(lon) || + lon < -180 || + lon > 180 + ) { + throw new Error('Coordenadas inválidas'); + } + + if (args.latitude !== undefined) atualizacoes.latitude = lat; + if (args.longitude !== undefined) atualizacoes.longitude = lon; + } + + if (args.endereco !== undefined) { + if (args.endereco.trim().length === 0) { + throw new Error('Endereço não pode ser vazio'); + } + atualizacoes.endereco = args.endereco.trim(); + } + + if (args.bairro !== undefined) { + atualizacoes.bairro = args.bairro?.trim() || undefined; + } + + if (args.cep !== undefined) { + atualizacoes.cep = args.cep?.trim() || undefined; + } + + if (args.cidade !== undefined) { + if (args.cidade.trim().length === 0) { + throw new Error('Cidade não pode ser vazia'); + } + atualizacoes.cidade = args.cidade.trim(); + } + + if (args.estado !== undefined) { + if (args.estado.trim().length === 0) { + throw new Error('Estado não pode ser vazio'); + } + atualizacoes.estado = args.estado.trim(); + } + + if (args.pais !== undefined) { + atualizacoes.pais = args.pais?.trim() || 'Brasil'; + } + + if (args.raioMetros !== undefined) { + if (args.raioMetros < 0 || args.raioMetros > 50000) { + throw new Error('Raio deve estar entre 0 e 50000 metros'); + } + atualizacoes.raioMetros = args.raioMetros; + } + + if (args.tipo !== undefined) { + atualizacoes.tipo = args.tipo; + } + + if (args.ativo !== undefined) { + atualizacoes.ativo = args.ativo; + } + + atualizacoes.atualizadoPor = usuario._id as Id<'usuarios'>; + atualizacoes.atualizadoEm = Date.now(); + + await ctx.db.patch(args.enderecoId, atualizacoes); + + return { sucesso: true }; + }, +}); + +/** + * Desativa um endereço de marcação + */ +export const desativarEndereco = mutation({ + args: { + enderecoId: v.id('enderecosMarcacao'), + }, + handler: async (ctx, args) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) { + throw new Error('Usuário não autenticado'); + } + + // TODO: Verificar permissões (apenas TI ou admin) + + const endereco = await ctx.db.get(args.enderecoId); + if (!endereco) { + throw new Error('Endereço não encontrado'); + } + + await ctx.db.patch(args.enderecoId, { + ativo: false, + atualizadoPor: usuario._id as Id<'usuarios'>, + atualizadoEm: Date.now(), + }); + + return { sucesso: true }; + }, +}); + +/** + * Obtém endereços permitidos para um funcionário + * (leva em conta associações específicas e endereços tipo "sede") + */ +export const obterEnderecosFuncionario = query({ + args: { + funcionarioId: v.id('funcionarios'), + dataAtual: v.optional(v.string()), // YYYY-MM-DD para validar períodos + }, + handler: async (ctx, args) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) { + throw new Error('Usuário não autenticado'); + } + + const dataAtual = args.dataAtual || new Date().toISOString().split('T')[0]!; + + // Buscar associações específicas do funcionário + const associacoes = await ctx.db + .query('funcionarioEnderecosMarcacao') + .withIndex('by_funcionario_ativo', (q) => + q.eq('funcionarioId', args.funcionarioId).eq('ativo', true) + ) + .collect(); + + const enderecosPermitidos: Array<{ + enderecoId: Id<'enderecosMarcacao'>; + raioMetros: number; + periodoValido: boolean; + }> = []; + + // Processar associações + for (const associacao of associacoes) { + // Verificar período de validade + let periodoValido = true; + if (associacao.dataInicio || associacao.dataFim) { + if (associacao.dataInicio && dataAtual < associacao.dataInicio) { + periodoValido = false; + } + if (associacao.dataFim && dataAtual > associacao.dataFim) { + periodoValido = false; + } + } + + if (!periodoValido) continue; + + const endereco = await ctx.db.get(associacao.enderecoMarcacaoId); + if (!endereco || !endereco.ativo) continue; + + // Usar raio personalizado se disponível, senão usar o raio do endereço + const raioMetros = + associacao.raioMetrosPersonalizado ?? endereco.raioMetros; + + enderecosPermitidos.push({ + enderecoId: endereco._id, + raioMetros, + periodoValido: true, + }); + } + + // Se não houver associações específicas, buscar endereços tipo "sede" + if (enderecosPermitidos.length === 0) { + const enderecosSede = await ctx.db + .query('enderecosMarcacao') + .withIndex('by_tipo', (q) => q.eq('tipo', 'sede')) + .filter((q) => q.eq(q.field('ativo'), true)) + .collect(); + + for (const endereco of enderecosSede) { + enderecosPermitidos.push({ + enderecoId: endereco._id, + raioMetros: endereco.raioMetros, + periodoValido: true, + }); + } + } + + // Buscar dados completos dos endereços + const enderecosCompletos = await Promise.all( + enderecosPermitidos.map(async (item) => { + const endereco = await ctx.db.get(item.enderecoId); + if (!endereco) return null; + + return { + ...endereco, + raioMetros: item.raioMetros, + periodoValido: item.periodoValido, + }; + }) + ); + + return enderecosCompletos.filter( + (endereco): endereco is NonNullable => endereco !== null + ); + }, +}); + +/** + * Função auxiliar interna para validar geofencing + * Pode ser chamada diretamente de outras mutations + */ +async function validarLocalizacaoGeofencingInternal( + ctx: MutationCtx, + funcionarioId: Id<'funcionarios'>, + latitude: number, + longitude: number, + raioPadrao: number = 100 +): Promise<{ + dentroRaio: boolean; + enderecoMaisProximo?: Id<'enderecosMarcacao'>; + distanciaMetros?: number; + raioUsado?: number; + enderecoEncontrado?: string; + avisos: string[]; +}> { + const avisos: string[] = []; + + // Validar coordenadas + if ( + isNaN(latitude) || + latitude < -90 || + latitude > 90 || + isNaN(longitude) || + longitude < -180 || + longitude > 180 + ) { + return { + dentroRaio: false, + avisos: ['Coordenadas inválidas'], + }; + } + + // Obter endereços permitidos para o funcionário + const dataAtual = new Date().toISOString().split('T')[0]!; + const associacoes = await ctx.db + .query('funcionarioEnderecosMarcacao') + .withIndex('by_funcionario_ativo', (q) => + q.eq('funcionarioId', funcionarioId).eq('ativo', true) + ) + .collect(); + + const enderecosParaValidar: Array<{ + enderecoId: Id<'enderecosMarcacao'>; + raioMetros: number; + }> = []; + + // Processar associações específicas + for (const associacao of associacoes) { + let periodoValido = true; + if (associacao.dataInicio || associacao.dataFim) { + if (associacao.dataInicio && dataAtual < associacao.dataInicio) { + periodoValido = false; + } + if (associacao.dataFim && dataAtual > associacao.dataFim) { + periodoValido = false; + } + } + + if (!periodoValido) continue; + + const endereco = await ctx.db.get(associacao.enderecoMarcacaoId); + if (!endereco || !endereco.ativo) continue; + + const raioMetros = + associacao.raioMetrosPersonalizado ?? endereco.raioMetros; + + enderecosParaValidar.push({ + enderecoId: endereco._id, + raioMetros, + }); + } + + // Se não houver associações específicas, buscar endereços tipo "sede" + if (enderecosParaValidar.length === 0) { + const enderecosSede = await ctx.db + .query('enderecosMarcacao') + .withIndex('by_tipo', (q) => q.eq('tipo', 'sede')) + .filter((q) => q.eq(q.field('ativo'), true)) + .collect(); + + for (const endereco of enderecosSede) { + enderecosParaValidar.push({ + enderecoId: endereco._id, + raioMetros: endereco.raioMetros, + }); + } + } + + // Se ainda não houver endereços, usar raio padrão + if (enderecosParaValidar.length === 0) { + avisos.push( + 'Nenhum endereço de marcação configurado. Usando validação padrão.' + ); + return { + dentroRaio: true, // Não bloquear se não houver configuração + raioUsado: raioPadrao, + avisos, + }; + } + + // Calcular distância para cada endereço e encontrar o mais próximo + let enderecoMaisProximo: Id<'enderecosMarcacao'> | undefined = undefined; + let distanciaMinima: number | undefined = undefined; + let raioUsado: number | undefined = undefined; + let enderecoEncontrado: string | undefined = undefined; + + for (const item of enderecosParaValidar) { + const endereco = await ctx.db.get(item.enderecoId); + if (!endereco) continue; + + const distancia = calcularDistancia( + latitude, + longitude, + endereco.latitude, + endereco.longitude + ); + + if (distanciaMinima === undefined || distancia < distanciaMinima) { + distanciaMinima = distancia; + enderecoMaisProximo = endereco._id; + raioUsado = item.raioMetros; + enderecoEncontrado = `${endereco.endereco}, ${endereco.cidade}/${endereco.estado}`; + } + } + + if (enderecoMaisProximo === undefined || distanciaMinima === undefined) { + return { + dentroRaio: false, + avisos: ['Não foi possível validar localização'], + }; + } + + const dentroRaio = distanciaMinima <= (raioUsado ?? raioPadrao); + + if (!dentroRaio) { + const distanciaKm = (distanciaMinima / 1000).toFixed(2); + const raioKm = ((raioUsado ?? raioPadrao) / 1000).toFixed(2); + avisos.push( + `Localização fora do raio permitido. Registrado a ${distanciaKm}km do endereço esperado (raio permitido: ${raioKm}km).` + ); + } + + return { + dentroRaio, + enderecoMaisProximo, + distanciaMetros: distanciaMinima, + raioUsado: raioUsado ?? raioPadrao, + enderecoEncontrado, + avisos, + }; +} + +/** + * Mutation pública para validar localização contra endereços permitidos (geofencing) + * Retorna o endereço mais próximo e se está dentro do raio + */ +export const validarLocalizacaoGeofencing = mutation({ + args: { + funcionarioId: v.id('funcionarios'), + latitude: v.number(), + longitude: v.number(), + raioPadrao: v.optional(v.number()), // metros - usado se não houver endereços configurados + }, + returns: v.object({ + dentroRaio: v.boolean(), + enderecoMaisProximo: v.optional(v.id('enderecosMarcacao')), + distanciaMetros: v.optional(v.number()), + raioUsado: v.optional(v.number()), + enderecoEncontrado: v.optional(v.string()), + avisos: v.array(v.string()), + }), + handler: async (ctx, args) => { + return await validarLocalizacaoGeofencingInternal( + ctx, + args.funcionarioId, + args.latitude, + args.longitude, + args.raioPadrao || 100 + ); + }, +}); + +/** + * Exporta a função auxiliar para uso interno em outras mutations + */ +export { validarLocalizacaoGeofencingInternal }; + + diff --git a/packages/backend/convex/funcionarioEnderecos.ts b/packages/backend/convex/funcionarioEnderecos.ts new file mode 100644 index 0000000..d11d6a9 --- /dev/null +++ b/packages/backend/convex/funcionarioEnderecos.ts @@ -0,0 +1,246 @@ +import { v } from 'convex/values'; +import { mutation, query } from './_generated/server'; +import { getCurrentUserFunction } from './auth'; +import type { Id } from './_generated/dataModel'; + +/** + * Lista todas as associações de endereços para um funcionário + */ +export const listarAssociacoesFuncionario = query({ + args: { + funcionarioId: v.id('funcionarios'), + incluirInativos: v.optional(v.boolean()), + }, + handler: async (ctx, args) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) { + throw new Error('Usuário não autenticado'); + } + + let associacoes; + if (args.incluirInativos) { + associacoes = await ctx.db + .query('funcionarioEnderecosMarcacao') + .withIndex('by_funcionario', (q) => q.eq('funcionarioId', args.funcionarioId)) + .collect(); + } else { + associacoes = await ctx.db + .query('funcionarioEnderecosMarcacao') + .withIndex('by_funcionario_ativo', (q) => + q.eq('funcionarioId', args.funcionarioId).eq('ativo', true) + ) + .collect(); + } + + // Buscar dados completos dos endereços + const associacoesCompletas = await Promise.all( + associacoes.map(async (associacao) => { + const endereco = await ctx.db.get(associacao.enderecoMarcacaoId); + if (!endereco) return null; + + // Usar raio personalizado se disponível, senão usar o raio padrão do endereço + const raioMetros = associacao.raioMetrosPersonalizado ?? endereco.raioMetros; + + return { + ...associacao, + raioMetros, // Raio usado (personalizado ou padrão) + endereco: { + _id: endereco._id, + nome: endereco.nome, + descricao: endereco.descricao, + latitude: endereco.latitude, + longitude: endereco.longitude, + endereco: endereco.endereco, + cep: endereco.cep, + cidade: endereco.cidade, + estado: endereco.estado, + pais: endereco.pais, + raioMetros: endereco.raioMetros, // Raio padrão do endereço + tipo: endereco.tipo, + ativo: endereco.ativo, + }, + }; + }) + ); + + return associacoesCompletas.filter( + (item): item is NonNullable => item !== null + ); + }, +}); + +/** + * Associa um endereço a um funcionário + */ +export const associarEnderecoFuncionario = mutation({ + args: { + funcionarioId: v.id('funcionarios'), + enderecoMarcacaoId: v.id('enderecosMarcacao'), + raioMetrosPersonalizado: v.optional(v.number()), + dataInicio: v.optional(v.string()), // YYYY-MM-DD + dataFim: v.optional(v.string()), // YYYY-MM-DD + }, + handler: async (ctx, args) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) { + throw new Error('Usuário não autenticado'); + } + + // TODO: Verificar permissões (apenas TI ou admin) + + // Validar que o funcionário existe + const funcionario = await ctx.db.get(args.funcionarioId); + if (!funcionario) { + throw new Error('Funcionário não encontrado'); + } + + // Validar que o endereço existe e está ativo + const endereco = await ctx.db.get(args.enderecoMarcacaoId); + if (!endereco) { + throw new Error('Endereço não encontrado'); + } + if (!endereco.ativo) { + throw new Error('Endereço não está ativo'); + } + + // Validar raio personalizado se fornecido + if (args.raioMetrosPersonalizado !== undefined) { + if (args.raioMetrosPersonalizado < 0 || args.raioMetrosPersonalizado > 50000) { + throw new Error('Raio deve estar entre 0 e 50000 metros'); + } + } + + // Validar datas se fornecidas + if (args.dataInicio && args.dataFim) { + if (args.dataInicio > args.dataFim) { + throw new Error('Data de início deve ser anterior à data de fim'); + } + } + + // Verificar se já existe uma associação ativa + const associacaoExistente = await ctx.db + .query('funcionarioEnderecosMarcacao') + .withIndex('by_funcionario_ativo', (q) => + q.eq('funcionarioId', args.funcionarioId).eq('ativo', true) + ) + .filter((q) => q.eq(q.field('enderecoMarcacaoId'), args.enderecoMarcacaoId)) + .first(); + + if (associacaoExistente) { + // Atualizar associação existente + await ctx.db.patch(associacaoExistente._id, { + raioMetrosPersonalizado: args.raioMetrosPersonalizado, + dataInicio: args.dataInicio, + dataFim: args.dataFim, + ativo: true, + }); + + return { associacaoId: associacaoExistente._id, atualizado: true }; + } + + // Criar nova associação + const associacaoId = await ctx.db.insert('funcionarioEnderecosMarcacao', { + funcionarioId: args.funcionarioId, + enderecoMarcacaoId: args.enderecoMarcacaoId, + raioMetrosPersonalizado: args.raioMetrosPersonalizado, + dataInicio: args.dataInicio, + dataFim: args.dataFim, + ativo: true, + criadoPor: usuario._id as Id<'usuarios'>, + criadoEm: Date.now(), + }); + + return { associacaoId, atualizado: false }; + }, +}); + +/** + * Atualiza uma associação de endereço com funcionário + */ +export const atualizarAssociacao = mutation({ + args: { + associacaoId: v.id('funcionarioEnderecosMarcacao'), + raioMetrosPersonalizado: v.optional(v.number()), + dataInicio: v.optional(v.string()), + dataFim: v.optional(v.string()), + ativo: v.optional(v.boolean()), + }, + handler: async (ctx, args) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) { + throw new Error('Usuário não autenticado'); + } + + // TODO: Verificar permissões (apenas TI ou admin) + + const associacao = await ctx.db.get(args.associacaoId); + if (!associacao) { + throw new Error('Associação não encontrada'); + } + + const atualizacoes: { + raioMetrosPersonalizado?: number; + dataInicio?: string; + dataFim?: string; + ativo?: boolean; + } = {}; + + if (args.raioMetrosPersonalizado !== undefined) { + if (args.raioMetrosPersonalizado < 0 || args.raioMetrosPersonalizado > 50000) { + throw new Error('Raio deve estar entre 0 e 50000 metros'); + } + atualizacoes.raioMetrosPersonalizado = args.raioMetrosPersonalizado; + } + + if (args.dataInicio !== undefined) { + atualizacoes.dataInicio = args.dataInicio || undefined; + } + + if (args.dataFim !== undefined) { + atualizacoes.dataFim = args.dataFim || undefined; + } + + if (args.dataInicio && args.dataFim) { + if (args.dataInicio > args.dataFim) { + throw new Error('Data de início deve ser anterior à data de fim'); + } + } + + if (args.ativo !== undefined) { + atualizacoes.ativo = args.ativo; + } + + await ctx.db.patch(args.associacaoId, atualizacoes); + + return { sucesso: true }; + }, +}); + +/** + * Remove uma associação de endereço com funcionário (desativa) + */ +export const removerAssociacao = mutation({ + args: { + associacaoId: v.id('funcionarioEnderecosMarcacao'), + }, + handler: async (ctx, args) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) { + throw new Error('Usuário não autenticado'); + } + + // TODO: Verificar permissões (apenas TI ou admin) + + const associacao = await ctx.db.get(args.associacaoId); + if (!associacao) { + throw new Error('Associação não encontrada'); + } + + // Desativar ao invés de deletar + await ctx.db.patch(args.associacaoId, { + ativo: false, + }); + + return { sucesso: true }; + }, +}); diff --git a/packages/backend/convex/pontos.ts b/packages/backend/convex/pontos.ts index 6f8202c..5d1060a 100644 --- a/packages/backend/convex/pontos.ts +++ b/packages/backend/convex/pontos.ts @@ -3,6 +3,225 @@ import { mutation, query } from './_generated/server'; import type { MutationCtx, QueryCtx } from './_generated/server'; import { getCurrentUserFunction } from './auth'; import type { Id } from './_generated/dataModel'; +import { validarLocalizacaoGeofencingInternal } from './enderecosMarcacao'; + +/** + * Calcula distância entre duas coordenadas (fórmula de Haversine) + * Retorna distância em metros + */ +function calcularDistancia( + lat1: number, + lon1: number, + lat2: number, + lon2: number +): number { + const R = 6371000; // Raio da Terra em metros + const dLat = ((lat2 - lat1) * Math.PI) / 180; + const dLon = ((lon2 - lon1) * Math.PI) / 180; + const a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos((lat1 * Math.PI) / 180) * + Math.cos((lat2 * Math.PI) / 180) * + Math.sin(dLon / 2) * + Math.sin(dLon / 2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return R * c; +} + +/** + * Obtém geolocalização aproximada por IP usando serviço externo + */ +async function obterGeoPorIP(ipAddress: string): Promise<{ + latitude: number; + longitude: number; + cidade?: string; + estado?: string; + pais?: string; +} | null> { + try { + // Usar ipapi.co (gratuito, sem chave para uso limitado) + const response = await fetch(`https://ipapi.co/${ipAddress}/json/`, { + headers: { + 'User-Agent': 'SGSE-App/1.0' + } + }); + + if (response.ok) { + const data = (await response.json()) as { + latitude?: number; + longitude?: number; + city?: string; + region?: string; + country_name?: string; + error?: boolean; + }; + + if (!data.error && data.latitude && data.longitude) { + return { + latitude: data.latitude, + longitude: data.longitude, + cidade: data.city, + estado: data.region, + pais: data.country_name + }; + } + } + } catch (error) { + console.warn('Erro ao obter geolocalização por IP:', error); + } + + return null; +} + +/** + * Valida localização contra IP geolocation e histórico + * Retorna informações detalhadas para salvar no registro + */ +async function validarLocalizacao( + ctx: MutationCtx, + funcionarioId: Id<'funcionarios'>, + latitude: number, + longitude: number, + ipAddress?: string, + confiabilidadeGPS?: number +): Promise<{ + valida: boolean; + motivo?: string; + scoreConfianca: number; // 0-1 + avisos: string[]; + distanciaIPvsGPS?: number; // Distância em metros entre IP geolocation e GPS + velocidadeUltimoRegistro?: number; // Velocidade calculada em km/h + distanciaUltimoRegistro?: number; // Distância em metros do último registro + tempoDecorridoHoras?: number; // Tempo em horas desde último registro +}> { + const avisos: string[] = []; + let scoreConfianca = confiabilidadeGPS || 0.5; + let valida = true; + let distanciaIPvsGPS: number | undefined = undefined; + let velocidadeUltimoRegistro: number | undefined = undefined; + let distanciaUltimoRegistro: number | undefined = undefined; + let tempoDecorridoHoras: number | undefined = undefined; + + // 1. Validar coordenadas básicas + if ( + isNaN(latitude) || + isNaN(longitude) || + latitude < -90 || + latitude > 90 || + longitude < -180 || + longitude > 180 + ) { + return { + valida: false, + motivo: 'Coordenadas inválidas', + scoreConfianca: 0, + avisos: [], + distanciaIPvsGPS, + velocidadeUltimoRegistro, + distanciaUltimoRegistro, + tempoDecorridoHoras + }; + } + + // 2. Comparar com geolocalização do IP + if (ipAddress) { + const ipGeo = await obterGeoPorIP(ipAddress); + if (ipGeo) { + distanciaIPvsGPS = calcularDistancia( + latitude, + longitude, + ipGeo.latitude, + ipGeo.longitude + ); + + // Se diferença > 50km, muito suspeito + if (distanciaIPvsGPS > 50000) { + valida = false; + scoreConfianca = Math.min(scoreConfianca, 0.2); + avisos.push( + `Localização GPS (${latitude.toFixed(6)}, ${longitude.toFixed(6)}) está muito distante da localização do IP (${distanciaIPvsGPS.toFixed(0)}m). Possível falsificação.` + ); + } else if (distanciaIPvsGPS > 10000) { + // Se diferença entre 10-50km, suspeito mas aceitável (pode ser VPN/mobile) + scoreConfianca *= 0.7; + avisos.push( + `Localização GPS está a ${distanciaIPvsGPS.toFixed(0)}m da localização do IP. Isso pode ser normal se estiver usando VPN ou dados móveis.` + ); + } else if (distanciaIPvsGPS < 5000) { + // Se diferença < 5km, aumenta confiança + scoreConfianca = Math.min(scoreConfianca + 0.2, 1); + } + } + } + + // 3. Validar histórico de localizações do funcionário + const ultimosRegistros = await ctx.db + .query('registrosPonto') + .withIndex('by_funcionario_data', (q) => q.eq('funcionarioId', funcionarioId)) + .order('desc') + .take(5); + + if (ultimosRegistros.length > 0) { + // Verificar movimento impossível + for (const registro of ultimosRegistros) { + if (registro.latitude && registro.longitude && registro.timestamp) { + distanciaUltimoRegistro = calcularDistancia( + latitude, + longitude, + registro.latitude, + registro.longitude + ); + const tempoDecorrido = Date.now() - registro.timestamp; + tempoDecorridoHoras = tempoDecorrido / (1000 * 60 * 60); + + // Calcular velocidade (km/h) se tempo decorrido > 0 + if (tempoDecorridoHoras > 0 && tempoDecorridoHoras < 24) { + velocidadeUltimoRegistro = (distanciaUltimoRegistro / 1000) / tempoDecorridoHoras; // km/h + + // Se velocidade > 1000 km/h, impossível (mais rápido que avião) + if (velocidadeUltimoRegistro > 1000) { + valida = false; + scoreConfianca = 0; + avisos.push( + `Movimento impossível detectado: ${velocidadeUltimoRegistro.toFixed(0)} km/h. Localização anterior há ${tempoDecorridoHoras.toFixed(1)}h está a ${(distanciaUltimoRegistro / 1000).toFixed(1)}km.` + ); + break; + } + + // Se velocidade > 200 km/h, suspeito (mas possível em avião) + if (velocidadeUltimoRegistro > 200 && velocidadeUltimoRegistro <= 1000) { + scoreConfianca *= 0.6; + avisos.push( + `Movimento muito rápido: ${velocidadeUltimoRegistro.toFixed(0)} km/h. Pode ser viagem, mas verifique se é legítimo.` + ); + } + } + break; // Usar apenas o último registro + } + } + } + + // 4. Validar confiabilidade GPS do frontend + if (confiabilidadeGPS !== undefined) { + if (confiabilidadeGPS < 0.3) { + scoreConfianca *= 0.5; + avisos.push( + `Confiabilidade GPS baixa (${(confiabilidadeGPS * 100).toFixed(0)}%). Localização pode não ser precisa.` + ); + } + } + + return { + valida, + motivo: avisos.length > 0 ? avisos[0] : undefined, + scoreConfianca: Math.max(0, Math.min(1, scoreConfianca)), + avisos, + distanciaIPvsGPS, + velocidadeUltimoRegistro, + distanciaUltimoRegistro, + tempoDecorridoHoras + }; +} /** * Gera URL para upload de imagem do ponto @@ -96,6 +315,13 @@ export const registrarPonto = mutation({ latitude: v.optional(v.number()), longitude: v.optional(v.number()), precisao: v.optional(v.number()), + altitude: v.optional(v.union(v.number(), v.null())), + altitudeAccuracy: v.optional(v.union(v.number(), v.null())), + heading: v.optional(v.union(v.number(), v.null())), + speed: v.optional(v.union(v.number(), v.null())), + confiabilidadeGPS: v.optional(v.number()), + suspeitaSpoofing: v.optional(v.boolean()), + motivoSuspeita: v.optional(v.string()), endereco: v.optional(v.string()), cidade: v.optional(v.string()), estado: v.optional(v.string()), @@ -150,13 +376,31 @@ export const registrarPonto = mutation({ .first(); // Converter timestamp para data/hora com ajuste de GMT + // O timestamp está em UTC, precisamos aplicar o GMT offset const gmtOffset = configPonto?.gmtOffset ?? 0; - const timestampAjustado = args.timestamp + (gmtOffset * 60 * 60 * 1000); - const dataObj = new Date(timestampAjustado); - const data = dataObj.toISOString().split('T')[0]!; // YYYY-MM-DD - const hora = dataObj.getUTCHours(); - const minuto = dataObj.getUTCMinutes(); - const segundo = dataObj.getUTCSeconds(); + + // Calcular horário ajustado manualmente a partir de UTC + const dataUTC = new Date(args.timestamp); + let hora = dataUTC.getUTCHours() + gmtOffset; + const minuto = dataUTC.getUTCMinutes(); + const segundo = dataUTC.getUTCSeconds(); + + // Ajustar hora se ultrapassar os limites do dia + let diasOffset = 0; + if (hora >= 24) { + hora = hora - 24; + diasOffset = 1; + } else if (hora < 0) { + hora = hora + 24; + diasOffset = -1; + } + + // Calcular data ajustada + const dataAjustada = new Date(args.timestamp); + if (diasOffset !== 0) { + dataAjustada.setUTCDate(dataAjustada.getUTCDate() + diasOffset); + } + const data = dataAjustada.toISOString().split('T')[0]!; // YYYY-MM-DD // Verificar se já existe registro no mesmo minuto const funcionarioId = usuario.funcionarioId; // Já verificado acima, não é undefined @@ -244,6 +488,97 @@ export const registrarPonto = mutation({ const dentroDoPrazo = calcularStatusPonto(hora, minuto, horarioConfigurado, config.toleranciaMinutos); + // Validar localização se fornecida e salvar informações detalhadas + let validacaoLocalizacao: { + valida: boolean; + motivo?: string; + scoreConfianca: number; + avisos: string[]; + distanciaIPvsGPS?: number; + velocidadeUltimoRegistro?: number; + distanciaUltimoRegistro?: number; + tempoDecorridoHoras?: number; + } | null = null; + + if ( + args.informacoesDispositivo?.latitude && + args.informacoesDispositivo?.longitude + ) { + validacaoLocalizacao = await validarLocalizacao( + ctx, + usuario.funcionarioId, + args.informacoesDispositivo.latitude, + args.informacoesDispositivo.longitude, + args.informacoesDispositivo.ipPublico || args.informacoesDispositivo.ipAddress, + args.informacoesDispositivo.confiabilidadeGPS + ); + + // Sempre registrar, mesmo com baixa confiabilidade + // Mas salvar todas as informações detalhadas para análise posterior + const suspeitaFrontend = args.informacoesDispositivo.suspeitaSpoofing; + const suspeitaBackend = !validacaoLocalizacao.valida; + const baixaConfianca = validacaoLocalizacao.scoreConfianca < 0.5; + + if (suspeitaFrontend || suspeitaBackend || baixaConfianca) { + console.warn('⚠️ LOCALIZAÇÃO COM BAIXA CONFIABILIDADE DETECTADA (registrando normalmente):', { + funcionarioId: usuario.funcionarioId, + latitude: args.informacoesDispositivo.latitude, + longitude: args.informacoesDispositivo.longitude, + confiabilidadeGPSFrontend: args.informacoesDispositivo.confiabilidadeGPS, + scoreConfiancaBackend: validacaoLocalizacao.scoreConfianca, + suspeitaFrontend: suspeitaFrontend ? args.informacoesDispositivo.motivoSuspeita : null, + suspeitaBackend: suspeitaBackend ? validacaoLocalizacao.motivo : null, + avisos: validacaoLocalizacao.avisos + }); + } + } + + // Validar geofencing (localização permitida) se habilitado + let validacaoGeofencing: { + dentroRaio: boolean; + enderecoMaisProximo?: Id<'enderecosMarcacao'>; + distanciaMetros?: number; + raioUsado?: number; + enderecoEncontrado?: string; + avisos: string[]; + } | null = null; + + if ( + configPonto?.validarLocalizacao !== false && + args.informacoesDispositivo?.latitude && + args.informacoesDispositivo?.longitude + ) { + const geofencing = await validarLocalizacaoGeofencingInternal( + ctx, + usuario.funcionarioId, + args.informacoesDispositivo.latitude, + args.informacoesDispositivo.longitude, + configPonto?.toleranciaDistanciaMetros ?? 100 + ); + + validacaoGeofencing = geofencing; + + // Adicionar avisos de geofencing aos avisos de validação + if (geofencing.avisos.length > 0) { + if (!validacaoLocalizacao) { + validacaoLocalizacao = { + valida: true, + scoreConfianca: 1, + avisos: [], + }; + } + validacaoLocalizacao.avisos.push(...geofencing.avisos); + + // Reduzir score de confiança se estiver fora do raio + if (!geofencing.dentroRaio) { + validacaoLocalizacao.scoreConfianca = Math.min( + validacaoLocalizacao.scoreConfianca, + 0.7 + ); + } + } + } + // Criar registro const registroId = await ctx.db.insert('registrosPonto', { funcionarioId: usuario.funcionarioId, @@ -272,6 +607,22 @@ export const registrarPonto = mutation({ latitude: args.informacoesDispositivo?.latitude, longitude: args.informacoesDispositivo?.longitude, precisao: args.informacoesDispositivo?.precisao, + altitude: args.informacoesDispositivo?.altitude, + altitudeAccuracy: args.informacoesDispositivo?.altitudeAccuracy, + heading: args.informacoesDispositivo?.heading, + speed: args.informacoesDispositivo?.speed, + confiabilidadeGPS: args.informacoesDispositivo?.confiabilidadeGPS, + scoreConfiancaBackend: validacaoLocalizacao?.scoreConfianca, + suspeitaSpoofing: args.informacoesDispositivo?.suspeitaSpoofing || (validacaoLocalizacao ? validacaoLocalizacao.scoreConfianca < 0.5 || !validacaoLocalizacao.valida : undefined), + motivoSuspeita: args.informacoesDispositivo?.motivoSuspeita || validacaoLocalizacao?.motivo || (validacaoLocalizacao && validacaoLocalizacao.avisos.length > 0 ? validacaoLocalizacao.avisos.join('; ') : undefined), + // Informações detalhadas de validação (sempre salvar quando houver validação) + avisosValidacao: validacaoLocalizacao && validacaoLocalizacao.avisos.length > 0 ? validacaoLocalizacao.avisos : undefined, + // Informações de Geofencing + enderecoMarcacaoEsperado: validacaoGeofencing?.enderecoMaisProximo, + distanciaEnderecoEsperado: validacaoGeofencing?.distanciaMetros, + dentroRaioPermitido: validacaoGeofencing?.dentroRaio, + enderecoMarcacaoUsado: validacaoGeofencing?.enderecoMaisProximo, + raioToleranciaUsado: validacaoGeofencing?.raioUsado, endereco: args.informacoesDispositivo?.endereco, cidade: args.informacoesDispositivo?.cidade, estado: args.informacoesDispositivo?.estado, @@ -384,15 +735,32 @@ export const listarRegistrosPeriodo = query({ const dataFim = new Date(args.dataFim); dataFim.setHours(23, 59, 59, 999); - const registros = await ctx.db - .query('registrosPonto') - .withIndex('by_data', (q) => q.gte('data', args.dataInicio).lte('data', args.dataFim)) - .collect(); - - // Filtrar por funcionário se especificado - let registrosFiltrados = registros; + let registrosFiltrados; + + // Se funcionário foi especificado, usar índice por funcionário e data (mais eficiente) if (args.funcionarioId) { - registrosFiltrados = registros.filter((r) => r.funcionarioId === args.funcionarioId); + // Garantir que funcionarioId não é undefined para TypeScript + const funcionarioId = args.funcionarioId; + + // Buscar todos os registros do funcionário + const todosRegistrosFuncionario = await ctx.db + .query('registrosPonto') + .withIndex('by_funcionario_data', (q) => q.eq('funcionarioId', funcionarioId)) + .collect(); + + // Filtrar por período de data + registrosFiltrados = todosRegistrosFuncionario.filter((r) => { + const dataRegistro = new Date(r.data); + return dataRegistro >= new Date(args.dataInicio) && dataRegistro <= dataFim; + }); + } else { + // Se não há funcionário especificado, buscar todos e filtrar (menos eficiente, mas necessário) + const registros = await ctx.db + .query('registrosPonto') + .withIndex('by_data', (q) => q.gte('data', args.dataInicio).lte('data', args.dataFim)) + .collect(); + + registrosFiltrados = registros; } // Buscar informações dos funcionários @@ -1052,6 +1420,48 @@ export const listarHomologacoes = query({ }, }); +/** + * Exclui uma homologação (apenas para gestores) + */ +export const excluirHomologacao = mutation({ + args: { + homologacaoId: v.id('homologacoesPonto'), + }, + handler: async (ctx, args) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) { + throw new Error('Usuário não autenticado'); + } + + const homologacao = await ctx.db.get(args.homologacaoId); + if (!homologacao) { + throw new Error('Homologação não encontrada'); + } + + // Verificar se é gestor do funcionário + const isGestor = await verificarGestorDoFuncionario(ctx, usuario._id, homologacao.funcionarioId); + if (!isGestor && homologacao.gestorId !== usuario._id) { + throw new Error('Você não tem permissão para excluir esta homologação'); + } + + // Se a homologação estiver vinculada a um registro, remover a referência + if (homologacao.registroId) { + const registro = await ctx.db.get(homologacao.registroId); + if (registro && registro.homologacaoId === args.homologacaoId) { + await ctx.db.patch(homologacao.registroId, { + homologacaoId: undefined, + editadoPorGestor: false, + }); + } + } + + // Excluir homologação + await ctx.db.delete(args.homologacaoId); + + return { success: true }; + }, +}); + /** * Obtém opções de motivos de atestados/declarações */ diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index 705e1f1..dccc159 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -1416,6 +1416,25 @@ export default defineSchema({ latitude: v.optional(v.number()), longitude: v.optional(v.number()), precisao: v.optional(v.number()), + altitude: v.optional(v.union(v.number(), v.null())), + altitudeAccuracy: v.optional(v.union(v.number(), v.null())), + heading: v.optional(v.union(v.number(), v.null())), + speed: v.optional(v.union(v.number(), v.null())), + confiabilidadeGPS: v.optional(v.number()), // 0-1 (frontend) + scoreConfiancaBackend: v.optional(v.number()), // 0-1 (backend) + suspeitaSpoofing: v.optional(v.boolean()), + motivoSuspeita: v.optional(v.string()), + avisosValidacao: v.optional(v.array(v.string())), // Array de avisos detalhados da validação + distanciaIPvsGPS: v.optional(v.number()), // Distância em metros entre IP geolocation e GPS + velocidadeUltimoRegistro: v.optional(v.number()), // Velocidade em km/h do último registro + distanciaUltimoRegistro: v.optional(v.number()), // Distância em metros do último registro + tempoDecorridoHoras: v.optional(v.number()), // Tempo em horas desde o último registro + // Informações de Geofencing + enderecoMarcacaoEsperado: v.optional(v.id("enderecosMarcacao")), // Endereço mais próximo esperado + distanciaEnderecoEsperado: v.optional(v.number()), // Distância em metros do endereço esperado + dentroRaioPermitido: v.optional(v.boolean()), // Se está dentro do raio permitido + enderecoMarcacaoUsado: v.optional(v.id("enderecosMarcacao")), // Qual endereço foi usado na validação + raioToleranciaUsado: v.optional(v.number()), // Raio usado na validação em metros endereco: v.optional(v.string()), cidade: v.optional(v.string()), estado: v.optional(v.string()), @@ -1450,6 +1469,60 @@ export default defineSchema({ .index("by_dentro_prazo", ["dentroDoPrazo", "data"]) .index("by_funcionario_timestamp", ["funcionarioId", "timestamp"]), + // Endereços de Marcação - Locais permitidos para registro de ponto + enderecosMarcacao: defineTable({ + nome: v.string(), // Ex: "Sede Principal", "Home Office João Silva", "Cliente ABC" + descricao: v.optional(v.string()), // Descrição opcional + // Coordenadas (obrigatórias) + latitude: v.number(), + longitude: v.number(), + // Endereço físico (para exibição) + endereco: v.string(), // Ex: "Rua Exemplo, 123" + bairro: v.optional(v.string()), // Bairro do endereço + cep: v.optional(v.string()), + cidade: v.string(), + estado: v.string(), + pais: v.optional(v.string()), // Padrão: "Brasil" + // Configurações + raioMetros: v.number(), // Raio de tolerância em metros (ex: 100m, 500m, 1000m) + ativo: v.boolean(), + // Tipos de uso + tipo: v.union( + v.literal("sede"), // Sede principal (para todos) + v.literal("home_office"), // Home office específico + v.literal("deslocamento"), // Deslocamento temporário + v.literal("cliente") // Local de cliente + ), + // Metadados + criadoPor: v.id("usuarios"), + criadoEm: v.number(), + atualizadoPor: v.optional(v.id("usuarios")), + atualizadoEm: v.optional(v.number()), + }) + .index("by_ativo", ["ativo"]) + .index("by_tipo", ["tipo"]) + .index("by_cidade", ["cidade"]), + + // Associação Funcionário ↔ Endereço de Marcação + funcionarioEnderecosMarcacao: defineTable({ + funcionarioId: v.id("funcionarios"), + enderecoMarcacaoId: v.id("enderecosMarcacao"), + // Configurações específicas do funcionário + raioMetrosPersonalizado: v.optional(v.number()), // Pode ter raio diferente do padrão + // Período de validade (para deslocamentos temporários) + dataInicio: v.optional(v.string()), // YYYY-MM-DD + dataFim: v.optional(v.string()), // YYYY-MM-DD + // Status + ativo: v.boolean(), + // Metadados + criadoPor: v.id("usuarios"), + criadoEm: v.number(), + }) + .index("by_funcionario", ["funcionarioId"]) + .index("by_endereco", ["enderecoMarcacaoId"]) + .index("by_funcionario_ativo", ["funcionarioId", "ativo"]) + .index("by_endereco_ativo", ["enderecoMarcacaoId", "ativo"]), + configuracaoPonto: defineTable({ horarioEntrada: v.string(), // HH:mm horarioSaidaAlmoco: v.string(), // HH:mm @@ -1463,6 +1536,9 @@ export default defineSchema({ nomeSaida: v.optional(v.string()), // Padrão: "Saída 2" // Ajuste de fuso horário (GMT offset em horas) gmtOffset: v.optional(v.number()), // Padrão: 0 (UTC) + // Configurações de geofencing + validarLocalizacao: v.optional(v.boolean()), // Habilitar/desabilitar validação de localização + toleranciaDistanciaMetros: v.optional(v.number()), // Raio padrão global em metros ativo: v.boolean(), atualizadoPor: v.id("usuarios"), atualizadoEm: v.number(),