feat: enhance point registration and location validation features
- Refactored the RegistroPonto component to improve the layout and user experience, including a new section for displaying standard hours. - Updated RelogioSincronizado to include GMT offset adjustments for accurate time display. - Introduced new location validation logic in the backend to ensure point registrations are within allowed geofenced areas. - Enhanced the device information schema to capture additional GPS data, improving the reliability of location checks. - Added new endpoints for managing allowed marking addresses, facilitating better control over where points can be registered.
This commit is contained in:
29
apps/web/CONFIGURACAO_ENV.md
Normal file
29
apps/web/CONFIGURACAO_ENV.md
Normal file
@@ -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`
|
||||||
|
|
||||||
174
apps/web/GOOGLE_MAPS_SETUP.md
Normal file
174
apps/web/GOOGLE_MAPS_SETUP.md
Normal file
@@ -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.
|
||||||
|
|
||||||
@@ -747,55 +747,13 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Relógio Sincronizado -->
|
|
||||||
<div class="card bg-base-100 shadow-xl">
|
|
||||||
<div class="card-body items-center">
|
|
||||||
<RelogioSincronizado />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Mapa de Horários -->
|
|
||||||
<div class="card bg-base-100 shadow-xl">
|
|
||||||
<div class="card-body">
|
|
||||||
<h2 class="card-title">
|
|
||||||
<Clock class="h-5 w-5" />
|
|
||||||
Horários do Dia
|
|
||||||
</h2>
|
|
||||||
<div class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
|
||||||
{#each mapaHorarios as horario (horario.tipo)}
|
|
||||||
<div
|
|
||||||
class="card {horario.registrado
|
|
||||||
? 'bg-success/10 border-success'
|
|
||||||
: 'bg-base-200'} border-2"
|
|
||||||
>
|
|
||||||
<div class="card-body p-4">
|
|
||||||
<div class="mb-2 flex items-center justify-between">
|
|
||||||
<span class="font-semibold">{horario.label}</span>
|
|
||||||
{#if horario.registrado}
|
|
||||||
{#if horario.dentroDoPrazo}
|
|
||||||
<CheckCircle2 class="text-success h-5 w-5" />
|
|
||||||
{:else}
|
|
||||||
<XCircle class="text-error h-5 w-5" />
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="text-2xl font-bold">{horario.horario}</div>
|
|
||||||
{#if horario.registrado}
|
|
||||||
<div class="text-base-content/70 text-sm">
|
|
||||||
Registrado: {horario.horarioRegistrado}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Botões de Registro -->
|
<!-- Botões de Registro -->
|
||||||
<div class="card bg-base-100 shadow-xl">
|
<div class="card bg-base-100 shadow-xl">
|
||||||
<div class="card-body items-center">
|
<div class="card-body items-center">
|
||||||
<h2 class="card-title mb-4">Registrar Ponto</h2>
|
<h2 class="card-title mb-4">Registrar Ponto</h2>
|
||||||
|
<div class="mb-6 w-full">
|
||||||
|
<RelogioSincronizado />
|
||||||
|
</div>
|
||||||
<div class="flex w-full flex-col items-center gap-4">
|
<div class="flex w-full flex-col items-center gap-4">
|
||||||
{#if sucesso}
|
{#if sucesso}
|
||||||
<div class="alert alert-success w-full">
|
<div class="alert alert-success w-full">
|
||||||
@@ -858,6 +816,78 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Mapa de Horários -->
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title mb-6">
|
||||||
|
<Clock class="h-5 w-5" />
|
||||||
|
Horário Padrão
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<!-- Linha horizontal com espaçamento uniforme -->
|
||||||
|
<div class="flex flex-wrap items-stretch justify-between gap-4 md:gap-6">
|
||||||
|
{#each mapaHorarios as horario (horario.tipo)}
|
||||||
|
<div class="flex-1 min-w-[140px] max-w-[220px] mx-auto">
|
||||||
|
<div
|
||||||
|
class="relative h-full rounded-xl border-2 transition-all duration-300 hover:shadow-lg {horario.registrado
|
||||||
|
? horario.dentroDoPrazo
|
||||||
|
? 'bg-gradient-to-br from-success/20 to-success/10 border-success shadow-md'
|
||||||
|
: 'bg-gradient-to-br from-error/20 to-error/10 border-error shadow-md'
|
||||||
|
: 'bg-gradient-to-br from-base-200 to-base-300 border-base-300'} p-5"
|
||||||
|
>
|
||||||
|
<!-- Status Icon -->
|
||||||
|
<div class="absolute top-3 right-3">
|
||||||
|
{#if horario.registrado}
|
||||||
|
{#if horario.dentroDoPrazo}
|
||||||
|
<CheckCircle2 class="h-5 w-5 text-success" />
|
||||||
|
{:else}
|
||||||
|
<XCircle class="h-5 w-5 text-error" />
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<Clock class="h-5 w-5 text-base-content/30" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Label -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<span class="text-sm font-semibold text-base-content/80 uppercase tracking-wide">
|
||||||
|
{horario.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Horário Padrão -->
|
||||||
|
<div class="mb-2">
|
||||||
|
<div class="text-3xl font-bold text-primary font-mono">
|
||||||
|
{horario.horario}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Horário Registrado (se houver) -->
|
||||||
|
{#if horario.registrado}
|
||||||
|
<div class="mt-3 pt-3 border-t border-base-content/10">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="text-xs font-medium text-base-content/60">
|
||||||
|
Registrado:
|
||||||
|
</div>
|
||||||
|
<div class="text-sm font-bold text-base-content">
|
||||||
|
{horario.horarioRegistrado}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="mt-3 pt-3 border-t border-base-content/10">
|
||||||
|
<div class="text-xs text-base-content/40 italic">
|
||||||
|
Aguardando registro
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Histórico e Saldo do Dia -->
|
<!-- Histórico e Saldo do Dia -->
|
||||||
{#if historicoSaldo && registrosOrdenados.length > 0}
|
{#if historicoSaldo && registrosOrdenados.length > 0}
|
||||||
<div class="card bg-base-100 shadow-xl">
|
<div class="card bg-base-100 shadow-xl">
|
||||||
@@ -888,46 +918,89 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Lista de Registros -->
|
<!-- Timeline de Registros -->
|
||||||
<div class="divider"></div>
|
<div class="divider"></div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-4">
|
||||||
<h3 class="font-semibold">Registros Realizados</h3>
|
<h3 class="font-semibold">Timeline do Dia</h3>
|
||||||
<div class="space-y-3">
|
|
||||||
{#each registrosOrdenados as registro (registro._id)}
|
<!-- Timeline Visual com horários padrão e registros reais -->
|
||||||
<div class="card bg-base-200">
|
<div class="relative">
|
||||||
|
<!-- Linha vertical central da timeline -->
|
||||||
|
<div class="absolute left-1/2 top-0 bottom-0 w-1 bg-gradient-to-b from-primary/20 via-base-300 to-secondary/20 transform -translate-x-1/2"></div>
|
||||||
|
|
||||||
|
<!-- Container com duas colunas -->
|
||||||
|
<div class="grid grid-cols-2 gap-4 relative">
|
||||||
|
<!-- Coluna Entrada -->
|
||||||
|
<div class="space-y-4 pr-2">
|
||||||
|
<div class="sticky top-0 z-10 bg-base-100 pb-3 mb-2 border-b border-primary/20">
|
||||||
|
<h4 class="text-lg font-bold text-primary text-center flex items-center justify-center gap-2">
|
||||||
|
<LogIn class="h-5 w-5" />
|
||||||
|
Entradas
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#each registrosOrdenados.filter(r => r.tipo === 'entrada' || r.tipo === 'retorno_almoco') as registro (registro._id)}
|
||||||
|
<div class="relative">
|
||||||
|
<!-- Linha horizontal conectando à timeline -->
|
||||||
|
<div class="absolute right-0 top-6 w-full h-0.5 bg-base-300/50" style="width: calc(100% - 0.5rem);"></div>
|
||||||
|
|
||||||
|
<!-- Card do registro -->
|
||||||
|
<div class="card {registro.dentroDoPrazo ? 'bg-success/5 border-success/30' : 'bg-error/5 border-error/30'} border-2 shadow-md hover:shadow-lg transition-all">
|
||||||
<div class="card-body p-4">
|
<div class="card-body p-4">
|
||||||
<div class="flex items-start justify-between gap-4">
|
<!-- Tipo de registro e status -->
|
||||||
<div class="flex-1">
|
<div class="flex items-center gap-2 mb-2">
|
||||||
<div class="mb-1 flex items-center gap-2">
|
{#if registro.dentroDoPrazo}
|
||||||
<span class="font-semibold">
|
<CheckCircle2 class="h-4 w-4 text-success flex-shrink-0" />
|
||||||
|
{:else}
|
||||||
|
<XCircle class="h-4 w-4 text-error flex-shrink-0" />
|
||||||
|
{/if}
|
||||||
|
<span class="text-sm font-semibold text-base-content/80">
|
||||||
{config
|
{config
|
||||||
? getTipoRegistroLabel(registro.tipo, {
|
? getTipoRegistroLabel(registro.tipo, {
|
||||||
nomeEntrada: config.nomeEntrada,
|
nomeEntrada: config.nomeEntrada,
|
||||||
nomeSaidaAlmoco: config.nomeSaidaAlmoco,
|
|
||||||
nomeRetornoAlmoco: config.nomeRetornoAlmoco,
|
nomeRetornoAlmoco: config.nomeRetornoAlmoco,
|
||||||
nomeSaida: config.nomeSaida,
|
|
||||||
})
|
})
|
||||||
: getTipoRegistroLabel(registro.tipo)}
|
: getTipoRegistroLabel(registro.tipo)}
|
||||||
</span>
|
</span>
|
||||||
{#if registro.dentroDoPrazo}
|
|
||||||
<CheckCircle2 class="h-4 w-4 text-success" />
|
|
||||||
{:else}
|
|
||||||
<XCircle class="h-4 w-4 text-error" />
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
<p class="text-lg font-bold">
|
|
||||||
|
<!-- Horário registrado -->
|
||||||
|
<p class="text-3xl font-bold text-primary mb-1">
|
||||||
{formatarHoraPonto(registro.hora, registro.minuto)}
|
{formatarHoraPonto(registro.hora, registro.minuto)}
|
||||||
</p>
|
</p>
|
||||||
{#if registro.justificativa}
|
|
||||||
<div class="mt-2 rounded bg-base-300 p-2">
|
<!-- Comparação com horário esperado -->
|
||||||
<p class="text-xs font-semibold opacity-70">Justificativa:</p>
|
{#if config}
|
||||||
<p class="text-sm">{registro.justificativa}</p>
|
{@const horarioEsperado = registro.tipo === 'entrada' ? config.horarioEntrada : config.horarioRetornoAlmoco}
|
||||||
</div>
|
{@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`}
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2 text-xs mb-3">
|
||||||
|
<span class="text-base-content/50">Esperado:</span>
|
||||||
|
<span class="font-semibold">{horarioEsperado}</span>
|
||||||
|
{#if diferencaAbs > 0}
|
||||||
|
<span class="badge badge-xs {diferenca > 0 ? 'badge-warning' : 'badge-info'}">
|
||||||
|
{diferenca > 0 ? '+' : '-'}{diferencaTexto}
|
||||||
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-shrink-0">
|
{/if}
|
||||||
|
|
||||||
|
{#if registro.justificativa}
|
||||||
|
<div class="mt-2 rounded-lg bg-base-300/50 p-2 text-xs mb-3">
|
||||||
|
<p class="font-semibold opacity-70 mb-1">Justificativa:</p>
|
||||||
|
<p class="text-base-content/80">{registro.justificativa}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="btn btn-sm btn-outline btn-primary gap-2"
|
class="btn btn-sm btn-outline btn-primary gap-2 w-full"
|
||||||
onclick={() => imprimirComprovante(registro._id)}
|
onclick={() => imprimirComprovante(registro._id)}
|
||||||
title="Imprimir Comprovante"
|
title="Imprimir Comprovante"
|
||||||
>
|
>
|
||||||
@@ -937,8 +1010,132 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<!-- Mostrar horários esperados que não foram registrados -->
|
||||||
|
{#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)}
|
||||||
|
<div class="relative opacity-50">
|
||||||
|
<div class="absolute right-0 top-6 w-full h-0.5 bg-base-300/30 border-dashed" style="width: calc(100% - 0.5rem);"></div>
|
||||||
|
<div class="card bg-base-200/50 border border-dashed border-base-300">
|
||||||
|
<div class="card-body p-3">
|
||||||
|
<p class="text-xs text-base-content/50 mb-1">{horarioEsperado.label} (não registrado)</p>
|
||||||
|
<p class="text-xl font-bold text-base-content/40">{horarioEsperado.horario}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Coluna Saída -->
|
||||||
|
<div class="space-y-4 pl-2">
|
||||||
|
<div class="sticky top-0 z-10 bg-base-100 pb-3 mb-2 border-b border-secondary/20">
|
||||||
|
<h4 class="text-lg font-bold text-secondary text-center flex items-center justify-center gap-2">
|
||||||
|
<LogOut class="h-5 w-5" />
|
||||||
|
Saídas
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#each registrosOrdenados.filter(r => r.tipo === 'saida_almoco' || r.tipo === 'saida') as registro (registro._id)}
|
||||||
|
<div class="relative">
|
||||||
|
<!-- Linha horizontal conectando à timeline -->
|
||||||
|
<div class="absolute left-0 top-6 w-full h-0.5 bg-base-300/50" style="width: calc(100% - 0.5rem);"></div>
|
||||||
|
|
||||||
|
<!-- Card do registro -->
|
||||||
|
<div class="card {registro.dentroDoPrazo ? 'bg-success/5 border-success/30' : 'bg-error/5 border-error/30'} border-2 shadow-md hover:shadow-lg transition-all">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<!-- Tipo de registro e status -->
|
||||||
|
<div class="flex items-center gap-2 mb-2 justify-end">
|
||||||
|
<span class="text-sm font-semibold text-base-content/80">
|
||||||
|
{config
|
||||||
|
? getTipoRegistroLabel(registro.tipo, {
|
||||||
|
nomeSaidaAlmoco: config.nomeSaidaAlmoco,
|
||||||
|
nomeSaida: config.nomeSaida,
|
||||||
|
})
|
||||||
|
: getTipoRegistroLabel(registro.tipo)}
|
||||||
|
</span>
|
||||||
|
{#if registro.dentroDoPrazo}
|
||||||
|
<CheckCircle2 class="h-4 w-4 text-success flex-shrink-0" />
|
||||||
|
{:else}
|
||||||
|
<XCircle class="h-4 w-4 text-error flex-shrink-0" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Horário registrado -->
|
||||||
|
<p class="text-3xl font-bold text-secondary mb-1 text-right">
|
||||||
|
{formatarHoraPonto(registro.hora, registro.minuto)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Comparação com horário esperado -->
|
||||||
|
{#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`}
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2 text-xs mb-3 justify-end">
|
||||||
|
{#if diferencaAbs > 0}
|
||||||
|
<span class="badge badge-xs {diferenca > 0 ? 'badge-warning' : 'badge-info'}">
|
||||||
|
{diferenca > 0 ? '+' : '-'}{diferencaTexto}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<span class="font-semibold">{horarioEsperado}</span>
|
||||||
|
<span class="text-base-content/50">Esperado:</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if registro.justificativa}
|
||||||
|
<div class="mt-2 rounded-lg bg-base-300/50 p-2 text-xs mb-3">
|
||||||
|
<p class="font-semibold opacity-70 mb-1">Justificativa:</p>
|
||||||
|
<p class="text-base-content/80">{registro.justificativa}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-outline btn-primary gap-2 w-full"
|
||||||
|
onclick={() => imprimirComprovante(registro._id)}
|
||||||
|
title="Imprimir Comprovante"
|
||||||
|
>
|
||||||
|
<Printer class="h-4 w-4" />
|
||||||
|
Imprimir Comprovante
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
|
<!-- Mostrar horários esperados que não foram registrados -->
|
||||||
|
{#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)}
|
||||||
|
<div class="relative opacity-50">
|
||||||
|
<div class="absolute left-0 top-6 w-full h-0.5 bg-base-300/30 border-dashed" style="width: calc(100% - 0.5rem);"></div>
|
||||||
|
<div class="card bg-base-200/50 border border-dashed border-base-300">
|
||||||
|
<div class="card-body p-3">
|
||||||
|
<p class="text-xs text-base-content/50 mb-1 text-right">{horarioEsperado.label} (não registrado)</p>
|
||||||
|
<p class="text-xl font-bold text-base-content/40 text-right">{horarioEsperado.horario}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,36 +17,45 @@
|
|||||||
async function atualizarTempo() {
|
async function atualizarTempo() {
|
||||||
try {
|
try {
|
||||||
const config = await client.query(api.configuracaoRelogio.obterConfiguracao, {});
|
const config = await client.query(api.configuracaoRelogio.obterConfiguracao, {});
|
||||||
|
const gmtOffset = config.gmtOffset ?? 0;
|
||||||
|
|
||||||
|
let timestampBase: number;
|
||||||
|
|
||||||
if (config.usarServidorExterno) {
|
if (config.usarServidorExterno) {
|
||||||
try {
|
try {
|
||||||
const resultado = await client.action(api.configuracaoRelogio.sincronizarTempo, {});
|
const resultado = await client.action(api.configuracaoRelogio.sincronizarTempo, {});
|
||||||
if (resultado.sucesso && resultado.timestamp) {
|
if (resultado.sucesso && resultado.timestamp) {
|
||||||
tempoAtual = new Date(resultado.timestamp);
|
timestampBase = resultado.timestamp;
|
||||||
sincronizado = true;
|
sincronizado = true;
|
||||||
usandoServidorExterno = resultado.usandoServidorExterno || false;
|
usandoServidorExterno = resultado.usandoServidorExterno || false;
|
||||||
offsetSegundos = resultado.offsetSegundos || 0;
|
offsetSegundos = resultado.offsetSegundos || 0;
|
||||||
erro = null;
|
erro = null;
|
||||||
|
} else {
|
||||||
|
throw new Error('Falha ao sincronizar');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Erro ao sincronizar:', error);
|
console.warn('Erro ao sincronizar:', error);
|
||||||
if (config.fallbackParaPC) {
|
if (config.fallbackParaPC) {
|
||||||
tempoAtual = new Date(obterTempoPC());
|
timestampBase = obterTempoPC();
|
||||||
sincronizado = false;
|
sincronizado = false;
|
||||||
usandoServidorExterno = false;
|
usandoServidorExterno = false;
|
||||||
erro = 'Usando relógio do PC (falha na sincronização)';
|
erro = 'Usando relógio do PC (falha na sincronização)';
|
||||||
} else {
|
} else {
|
||||||
erro = 'Falha ao sincronizar tempo';
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Usar tempo do servidor Convex
|
// Usar tempo do servidor Convex
|
||||||
const tempoServidor = await obterTempoServidor(client);
|
timestampBase = await obterTempoServidor(client);
|
||||||
tempoAtual = new Date(tempoServidor);
|
|
||||||
sincronizado = true;
|
sincronizado = true;
|
||||||
usandoServidorExterno = false;
|
usandoServidorExterno = false;
|
||||||
erro = null;
|
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) {
|
} catch (error) {
|
||||||
console.error('Erro ao obter tempo:', error);
|
console.error('Erro ao obter tempo:', error);
|
||||||
tempoAtual = new Date(obterTempoPC());
|
tempoAtual = new Date(obterTempoPC());
|
||||||
|
|||||||
@@ -15,6 +15,13 @@ export interface InformacoesDispositivo {
|
|||||||
latitude?: number;
|
latitude?: number;
|
||||||
longitude?: number;
|
longitude?: number;
|
||||||
precisao?: 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;
|
endereco?: string;
|
||||||
cidade?: string;
|
cidade?: string;
|
||||||
estado?: 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<{
|
async function obterLocalizacao(): Promise<{
|
||||||
latitude?: number;
|
latitude?: number;
|
||||||
longitude?: number;
|
longitude?: number;
|
||||||
precisao?: number;
|
precisao?: number;
|
||||||
|
altitude?: number | null;
|
||||||
|
altitudeAccuracy?: number | null;
|
||||||
|
heading?: number | null;
|
||||||
|
speed?: number | null;
|
||||||
|
confiabilidadeGPS?: number;
|
||||||
|
suspeitaSpoofing?: boolean;
|
||||||
|
motivoSuspeita?: string;
|
||||||
endereco?: string;
|
endereco?: string;
|
||||||
cidade?: string;
|
cidade?: string;
|
||||||
estado?: string;
|
estado?: string;
|
||||||
@@ -246,53 +530,19 @@ async function obterLocalizacao(): Promise<{
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tentar múltiplas estratégias
|
// Usar múltiplas leituras para detectar spoofing
|
||||||
const estrategias = [
|
const localizacaoMultipla = await obterLocalizacaoMultipla();
|
||||||
// Estratégia 1: Alta precisão (mais lento, mas mais preciso)
|
|
||||||
{
|
if (!localizacaoMultipla.latitude || !localizacaoMultipla.longitude) {
|
||||||
enableHighAccuracy: true,
|
console.warn('Não foi possível obter localização');
|
||||||
timeout: 10000,
|
return {
|
||||||
maximumAge: 0
|
confiabilidadeGPS: 0,
|
||||||
},
|
suspeitaSpoofing: true,
|
||||||
// Estratégia 2: Precisão média (balanceado)
|
motivoSuspeita: 'Não foi possível obter localização'
|
||||||
{
|
};
|
||||||
enableHighAccuracy: false,
|
|
||||||
timeout: 8000,
|
|
||||||
maximumAge: 30000
|
|
||||||
},
|
|
||||||
// Estratégia 3: Rápido (usa cache)
|
|
||||||
{
|
|
||||||
enableHighAccuracy: false,
|
|
||||||
timeout: 5000,
|
|
||||||
maximumAge: 60000
|
|
||||||
}
|
}
|
||||||
];
|
|
||||||
|
|
||||||
for (const options of estrategias) {
|
const { latitude, longitude, precisao, altitude, altitudeAccuracy, heading, speed, confiabilidade, suspeitaSpoofing, motivoSuspeita } = localizacaoMultipla;
|
||||||
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);
|
|
||||||
|
|
||||||
navigator.geolocation.getCurrentPosition(
|
|
||||||
async (position) => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
const { latitude, longitude, accuracy } = position.coords;
|
|
||||||
|
|
||||||
// Validar coordenadas
|
|
||||||
if (isNaN(latitude) || isNaN(longitude) || latitude === 0 || longitude === 0) {
|
|
||||||
resolve({});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tentar obter endereço via reverse geocoding
|
// Tentar obter endereço via reverse geocoding
|
||||||
let endereco = '';
|
let endereco = '';
|
||||||
@@ -334,39 +584,41 @@ async function obterLocalizacao(): Promise<{
|
|||||||
console.warn('Erro ao obter endereço:', error);
|
console.warn('Erro ao obter endereço:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve({
|
// 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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Localização obtida com validações:', {
|
||||||
latitude,
|
latitude,
|
||||||
longitude,
|
longitude,
|
||||||
precisao: accuracy,
|
confiabilidade: confiabilidade.toFixed(2),
|
||||||
|
suspeitaSpoofing,
|
||||||
|
motivoSuspeita
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
precisao,
|
||||||
|
altitude,
|
||||||
|
altitudeAccuracy,
|
||||||
|
heading,
|
||||||
|
speed,
|
||||||
|
confiabilidadeGPS: confiabilidade,
|
||||||
|
suspeitaSpoofing: suspeitaSpoofing || false,
|
||||||
|
motivoSuspeita,
|
||||||
endereco,
|
endereco,
|
||||||
cidade,
|
cidade,
|
||||||
estado,
|
estado,
|
||||||
pais,
|
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;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Erro na estratégia de geolocalização:', error);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Se todas as estratégias falharam, retornar vazio
|
|
||||||
console.warn('Não foi possível obter localização após todas as tentativas');
|
|
||||||
return {};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -439,6 +691,13 @@ export async function obterInformacoesDispositivo(): Promise<InformacoesDisposit
|
|||||||
informacoes.latitude = localizacao.latitude;
|
informacoes.latitude = localizacao.latitude;
|
||||||
informacoes.longitude = localizacao.longitude;
|
informacoes.longitude = localizacao.longitude;
|
||||||
informacoes.precisao = localizacao.precisao;
|
informacoes.precisao = localizacao.precisao;
|
||||||
|
informacoes.altitude = localizacao.altitude ?? null;
|
||||||
|
informacoes.altitudeAccuracy = localizacao.altitudeAccuracy ?? null;
|
||||||
|
informacoes.heading = localizacao.heading ?? null;
|
||||||
|
informacoes.speed = localizacao.speed ?? null;
|
||||||
|
informacoes.confiabilidadeGPS = localizacao.confiabilidadeGPS;
|
||||||
|
informacoes.suspeitaSpoofing = localizacao.suspeitaSpoofing;
|
||||||
|
informacoes.motivoSuspeita = localizacao.motivoSuspeita;
|
||||||
informacoes.endereco = localizacao.endereco;
|
informacoes.endereco = localizacao.endereco;
|
||||||
informacoes.cidade = localizacao.cidade;
|
informacoes.cidade = localizacao.cidade;
|
||||||
informacoes.estado = localizacao.estado;
|
informacoes.estado = localizacao.estado;
|
||||||
|
|||||||
@@ -65,9 +65,9 @@
|
|||||||
$: needsScroll = filtered.length > 8;
|
$: needsScroll = filtered.length > 8;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<main class="container mx-auto px-4 py-4">
|
<main class="container mx-auto px-4 py-4 max-w-7xl flex flex-col" style="height: calc(100vh - 8rem); min-height: 600px;">
|
||||||
<!-- Breadcrumb -->
|
<!-- Breadcrumb -->
|
||||||
<div class="breadcrumbs mb-4 text-sm">
|
<div class="breadcrumbs mb-4 text-sm flex-shrink-0">
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href={resolve('/recursos-humanos')} class="text-primary hover:underline">Recursos Humanos</a></li>
|
<li><a href={resolve('/recursos-humanos')} class="text-primary hover:underline">Recursos Humanos</a></li>
|
||||||
<li>Funcionários</li>
|
<li>Funcionários</li>
|
||||||
@@ -75,7 +75,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Cabeçalho -->
|
<!-- Cabeçalho -->
|
||||||
<div class="mb-6">
|
<div class="mb-6 flex-shrink-0">
|
||||||
<div class="flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
|
<div class="flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<div class="rounded-xl bg-blue-500/20 p-3">
|
<div class="rounded-xl bg-blue-500/20 p-3">
|
||||||
@@ -99,7 +99,7 @@
|
|||||||
<p class="text-base-content/70">Gerencie os funcionários da secretaria</p>
|
<p class="text-base-content/70">Gerencie os funcionários da secretaria</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary btn-lg gap-2" onclick={navCadastro}>
|
<button class="btn btn-primary btn-lg gap-2 shadow-md hover:shadow-lg transition-all" onclick={navCadastro}>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
class="h-5 w-5"
|
class="h-5 w-5"
|
||||||
@@ -118,7 +118,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filtros -->
|
<!-- Filtros -->
|
||||||
<div class="card bg-base-100 mb-6 shadow-xl">
|
<div class="card bg-base-100/90 backdrop-blur-sm border border-base-300 mb-4 shadow-xl flex-shrink-0">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title mb-4 text-lg">
|
<h2 class="card-title mb-4 text-lg">
|
||||||
<svg
|
<svg
|
||||||
@@ -223,38 +223,80 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Container da Tabela com altura responsiva -->
|
||||||
|
<div class="flex-1 flex flex-col min-h-0">
|
||||||
<!-- Tabela de Funcionários -->
|
<!-- Tabela de Funcionários -->
|
||||||
<div class="card bg-base-100 shadow-xl">
|
<div class="card bg-base-100/90 backdrop-blur-sm border border-base-300 shadow-xl flex-1 flex flex-col min-h-0">
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0 flex-1 flex flex-col min-h-0">
|
||||||
<div class="overflow-x-auto">
|
<!-- Container com scroll -->
|
||||||
<div class="overflow-y-auto" style="max-height: {filtered.length > 8 ? '600px' : 'none'};">
|
<div class="flex-1 overflow-hidden flex flex-col">
|
||||||
<table class="table-zebra table w-full">
|
<div class="overflow-x-auto flex-1 overflow-y-auto">
|
||||||
<thead class="bg-base-200 sticky top-0 z-10">
|
<table class="table table-zebra w-full">
|
||||||
|
<thead class="sticky top-0 z-10 shadow-md bg-gradient-to-r from-base-300 to-base-200">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="font-bold">Nome</th>
|
<th class="whitespace-nowrap font-bold text-base-content border-b border-base-400">Nome</th>
|
||||||
<th class="font-bold">CPF</th>
|
<th class="whitespace-nowrap font-bold text-base-content border-b border-base-400">CPF</th>
|
||||||
<th class="font-bold">Matrícula</th>
|
<th class="whitespace-nowrap font-bold text-base-content border-b border-base-400">Matrícula</th>
|
||||||
<th class="font-bold">Tipo</th>
|
<th class="whitespace-nowrap font-bold text-base-content border-b border-base-400">Tipo</th>
|
||||||
<th class="font-bold">Cidade</th>
|
<th class="whitespace-nowrap font-bold text-base-content border-b border-base-400">Cidade</th>
|
||||||
<th class="font-bold">UF</th>
|
<th class="whitespace-nowrap font-bold text-base-content border-b border-base-400">UF</th>
|
||||||
<th class="text-right font-bold">Ações</th>
|
<th class="text-right whitespace-nowrap font-bold text-base-content border-b border-base-400">Ações</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
{#if filtered.length === 0}
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="text-center py-12">
|
||||||
|
<div class="flex flex-col items-center justify-center gap-4">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-16 w-16 text-base-content/30"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="1.5"
|
||||||
|
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div class="text-base-content/60 text-center">
|
||||||
|
<p class="font-semibold text-lg mb-1">Nenhum funcionário encontrado</p>
|
||||||
|
<p class="text-sm">
|
||||||
|
{#if filtroNome || filtroCPF || filtroMatricula || filtroTipo}
|
||||||
|
Tente ajustar os filtros ou
|
||||||
|
{/if}
|
||||||
|
<button class="btn btn-link btn-sm p-0 h-auto min-h-0" onclick={navCadastro}>
|
||||||
|
cadastre um novo funcionário
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{:else}
|
||||||
{#each filtered as f}
|
{#each filtered as f}
|
||||||
<tr class="hover">
|
<tr class="hover:bg-base-200/50 transition-colors">
|
||||||
<td class="font-medium">{f.nome}</td>
|
<td class="whitespace-nowrap font-medium">{f.nome}</td>
|
||||||
<td>{f.cpf}</td>
|
<td class="whitespace-nowrap">{f.cpf}</td>
|
||||||
<td>{f.matricula}</td>
|
<td class="whitespace-nowrap">{f.matricula}</td>
|
||||||
<td>{f.simboloTipo}</td>
|
<td class="whitespace-nowrap">
|
||||||
<td>{f.cidade}</td>
|
<span class="badge badge-outline badge-sm">
|
||||||
<td>{f.uf}</td>
|
{f.simboloTipo === 'cargo_comissionado' ? 'Cargo Comissionado' :
|
||||||
<td class="text-right">
|
f.simboloTipo === 'funcao_gratificada' ? 'Função Gratificada' :
|
||||||
|
f.simboloTipo || '-'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap">{f.cidade || '-'}</td>
|
||||||
|
<td class="whitespace-nowrap">{f.uf || '-'}</td>
|
||||||
|
<td class="text-right whitespace-nowrap">
|
||||||
<div class="dropdown dropdown-end" class:dropdown-open={openMenuId === f._id}>
|
<div class="dropdown dropdown-end" class:dropdown-open={openMenuId === f._id}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Abrir menu"
|
aria-label="Abrir menu"
|
||||||
class="btn btn-sm"
|
class="btn btn-sm btn-ghost hover:btn-primary transition-all"
|
||||||
onclick={() => toggleMenu(f._id)}
|
onclick={() => toggleMenu(f._id)}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -262,33 +304,41 @@
|
|||||||
class="h-5 w-5"
|
class="h-5 w-5"
|
||||||
viewBox="0 0 20 20"
|
viewBox="0 0 20 20"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
><path
|
|
||||||
d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z"
|
|
||||||
/></svg
|
|
||||||
>
|
>
|
||||||
|
<path
|
||||||
|
d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<ul
|
<ul
|
||||||
class="dropdown-content menu bg-base-100 rounded-box border-base-300 z-10 w-52 border p-2 shadow-lg"
|
class="dropdown-content menu bg-base-100 rounded-box border-base-300 z-20 w-52 border p-2 shadow-xl"
|
||||||
>
|
>
|
||||||
<li>
|
<li>
|
||||||
<a href={`/recursos-humanos/funcionarios/${f._id}`}>Ver Detalhes</a>
|
<a href={`/recursos-humanos/funcionarios/${f._id}`} class="hover:bg-primary/10">
|
||||||
|
Ver Detalhes
|
||||||
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href={`/recursos-humanos/funcionarios/${f._id}/editar`}>Editar</a>
|
<a href={`/recursos-humanos/funcionarios/${f._id}/editar`} class="hover:bg-primary/10">
|
||||||
|
Editar
|
||||||
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href={`/recursos-humanos/funcionarios/${f._id}/documentos`}
|
<a href={`/recursos-humanos/funcionarios/${f._id}/documentos`} class="hover:bg-primary/10">
|
||||||
>Ver Documentos</a
|
Ver Documentos
|
||||||
>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<button onclick={() => openPrintModal(f._id)}>Imprimir Ficha</button>
|
<button onclick={() => openPrintModal(f._id)} class="hover:bg-primary/10">
|
||||||
|
Imprimir Ficha
|
||||||
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
|
{/if}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -297,8 +347,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Informação sobre resultados -->
|
<!-- Informação sobre resultados -->
|
||||||
<div class="text-base-content/70 mt-4 text-center text-sm">
|
<div class="text-base-content/70 mt-3 text-center text-sm flex-shrink-0 border-t border-base-300 pt-3 bg-base-100/50">
|
||||||
Exibindo {filtered.length} de {list.length} funcionário(s)
|
Exibindo <span class="font-semibold">{filtered.length}</span> de <span class="font-semibold">{list.length}</span> funcionário(s)
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Modal de Impressão -->
|
<!-- Modal de Impressão -->
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
APOSENTADO_OPTIONS,
|
APOSENTADO_OPTIONS,
|
||||||
} from "$lib/utils/constants";
|
} from "$lib/utils/constants";
|
||||||
import PrintModal from "$lib/components/PrintModal.svelte";
|
import PrintModal from "$lib/components/PrintModal.svelte";
|
||||||
|
import { MapPin } from "lucide-svelte";
|
||||||
|
|
||||||
const client = useConvexClient();
|
const client = useConvexClient();
|
||||||
|
|
||||||
@@ -203,6 +204,16 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Imprimir Ficha
|
Imprimir Ficha
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-info gap-2"
|
||||||
|
onclick={() =>
|
||||||
|
goto(
|
||||||
|
resolve(`/recursos-humanos/funcionarios/${funcionarioId}/enderecos-marcacao`),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<MapPin class="h-5 w-5" />
|
||||||
|
Endereços de Marcação
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,497 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { useQuery, useConvexClient } from 'convex-svelte';
|
||||||
|
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||||
|
import { MapPin, Plus, X, Edit, Trash2, Search } from 'lucide-svelte';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
|
||||||
|
const client = useConvexClient();
|
||||||
|
const funcionarioId = $derived($page.params.funcionarioId as Id<'funcionarios'>);
|
||||||
|
|
||||||
|
// Queries
|
||||||
|
const funcionarioQuery = useQuery(
|
||||||
|
api.funcionarios.getFichaCompleta,
|
||||||
|
funcionarioId ? { id: funcionarioId } : 'skip'
|
||||||
|
);
|
||||||
|
const associacoesQuery = useQuery(
|
||||||
|
api.funcionarioEnderecos.listarAssociacoesFuncionario,
|
||||||
|
funcionarioId ? { funcionarioId, incluirInativos: true } : 'skip'
|
||||||
|
);
|
||||||
|
const enderecosDisponiveisQuery = useQuery(api.enderecosMarcacao.listarEnderecos, {
|
||||||
|
incluirInativos: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const funcionario = $derived(funcionarioQuery?.data);
|
||||||
|
const associacoes = $derived(associacoesQuery?.data || []);
|
||||||
|
const enderecosDisponiveis = $derived(enderecosDisponiveisQuery?.data || []);
|
||||||
|
|
||||||
|
// Estados
|
||||||
|
let mostrarModalAssociacao = $state(false);
|
||||||
|
let editandoAssociacao: (typeof associacoes)[number] | null = $state(null);
|
||||||
|
let processando = $state(false);
|
||||||
|
let termoBusca = $state('');
|
||||||
|
|
||||||
|
// Campos do formulário
|
||||||
|
let enderecoSelecionado: Id<'enderecosMarcacao'> | '' = $state('');
|
||||||
|
let raioPersonalizado: number | '' = $state('');
|
||||||
|
let dataInicio = $state('');
|
||||||
|
let dataFim = $state('');
|
||||||
|
|
||||||
|
// Filtrar associacoes
|
||||||
|
const associacoesFiltradas = $derived(
|
||||||
|
associacoes.filter((a) => {
|
||||||
|
if (!termoBusca) return true;
|
||||||
|
const busca = termoBusca.toLowerCase();
|
||||||
|
return (
|
||||||
|
a.endereco.nome.toLowerCase().includes(busca) ||
|
||||||
|
a.endereco.endereco.toLowerCase().includes(busca) ||
|
||||||
|
a.endereco.cidade.toLowerCase().includes(busca) ||
|
||||||
|
a.endereco.tipo.toLowerCase().includes(busca)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Endereços já associados (para não mostrar no select)
|
||||||
|
const enderecosJaAssociados = $derived(
|
||||||
|
new Set(associacoes.filter((a) => a.ativo).map((a) => a.endereco._id))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Endereços disponíveis para associar (não associados e ativos)
|
||||||
|
const enderecosParaAssociar = $derived(
|
||||||
|
enderecosDisponiveis.filter((e) => e.ativo && !enderecosJaAssociados.has(e._id))
|
||||||
|
);
|
||||||
|
|
||||||
|
function limparFormulario() {
|
||||||
|
enderecoSelecionado = '';
|
||||||
|
raioPersonalizado = '';
|
||||||
|
dataInicio = '';
|
||||||
|
dataFim = '';
|
||||||
|
editandoAssociacao = null;
|
||||||
|
mostrarModalAssociacao = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function abrirFormularioEdicao(associacao: (typeof associacoes)[number]) {
|
||||||
|
editandoAssociacao = associacao;
|
||||||
|
enderecoSelecionado = associacao.endereco._id;
|
||||||
|
raioPersonalizado = associacao.raioMetrosPersonalizado ?? '';
|
||||||
|
dataInicio = associacao.dataInicio || '';
|
||||||
|
dataFim = associacao.dataFim || '';
|
||||||
|
mostrarModalAssociacao = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function salvarAssociacao() {
|
||||||
|
if (!enderecoSelecionado) {
|
||||||
|
toast.error('Selecione um endereço');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raioPersonalizado !== '' && (raioPersonalizado < 0 || raioPersonalizado > 50000)) {
|
||||||
|
toast.error('Raio personalizado deve estar entre 0 e 50000 metros');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dataInicio && dataFim && dataInicio > dataFim) {
|
||||||
|
toast.error('Data de início deve ser anterior à data de fim');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
processando = true;
|
||||||
|
try {
|
||||||
|
if (editandoAssociacao) {
|
||||||
|
await client.mutation(api.funcionarioEnderecos.atualizarAssociacao, {
|
||||||
|
associacaoId: editandoAssociacao._id,
|
||||||
|
raioMetrosPersonalizado:
|
||||||
|
raioPersonalizado !== '' ? Number(raioPersonalizado) : undefined,
|
||||||
|
dataInicio: dataInicio || undefined,
|
||||||
|
dataFim: dataFim || undefined,
|
||||||
|
});
|
||||||
|
toast.success('Associação atualizada com sucesso!');
|
||||||
|
} else {
|
||||||
|
await client.mutation(api.funcionarioEnderecos.associarEnderecoFuncionario, {
|
||||||
|
funcionarioId,
|
||||||
|
enderecoMarcacaoId: enderecoSelecionado as Id<'enderecosMarcacao'>,
|
||||||
|
raioMetrosPersonalizado:
|
||||||
|
raioPersonalizado !== '' ? Number(raioPersonalizado) : undefined,
|
||||||
|
dataInicio: dataInicio || undefined,
|
||||||
|
dataFim: dataFim || undefined,
|
||||||
|
});
|
||||||
|
toast.success('Endereço associado com sucesso!');
|
||||||
|
}
|
||||||
|
|
||||||
|
limparFormulario();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao salvar associação:', error);
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : 'Erro ao salvar associação. Tente novamente.'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
processando = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removerAssociacao(associacaoId: Id<'funcionarioEnderecosMarcacao'>) {
|
||||||
|
if (!confirm('Tem certeza que deseja remover esta associação?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.mutation(api.funcionarioEnderecos.removerAssociacao, { associacaoId });
|
||||||
|
toast.success('Associação removida com sucesso!');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao remover associação:', error);
|
||||||
|
toast.error('Erro ao remover associação. Tente novamente.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se funcionário existe
|
||||||
|
onMount(() => {
|
||||||
|
if (!funcionarioId) {
|
||||||
|
goto(resolve('/recursos-humanos/funcionarios'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const tiposLabel: Record<string, string> = {
|
||||||
|
sede: 'Sede Principal',
|
||||||
|
home_office: 'Home Office',
|
||||||
|
deslocamento: 'Deslocamento',
|
||||||
|
cliente: 'Cliente',
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="container mx-auto px-4 py-6 max-w-7xl">
|
||||||
|
<!-- Breadcrumb -->
|
||||||
|
<div class="text-sm breadcrumbs mb-4">
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<a href={resolve('/recursos-humanos')} class="text-primary hover:underline">
|
||||||
|
Recursos Humanos
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href={resolve('/recursos-humanos/funcionarios')}
|
||||||
|
class="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Funcionários
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{#if funcionario}
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href={resolve(`/recursos-humanos/funcionarios/${funcionarioId}`)}
|
||||||
|
class="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{funcionario.nome}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/if}
|
||||||
|
<li>Endereços de Marcação</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="p-3 bg-primary/10 rounded-xl">
|
||||||
|
<MapPin class="h-8 w-8 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-base-content">Endereços de Marcação</h1>
|
||||||
|
<p class="text-base-content/60 mt-1">
|
||||||
|
{#if funcionario}
|
||||||
|
Gerenciar locais permitidos para registro de ponto de {funcionario.nome}
|
||||||
|
{:else}
|
||||||
|
Carregando...
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if enderecosParaAssociar.length > 0}
|
||||||
|
<button
|
||||||
|
class="btn btn-primary gap-2"
|
||||||
|
onclick={() => {
|
||||||
|
limparFormulario();
|
||||||
|
mostrarModalAssociacao = true;
|
||||||
|
}}
|
||||||
|
disabled={mostrarModalAssociacao}
|
||||||
|
>
|
||||||
|
<Plus class="h-5 w-5" />
|
||||||
|
Associar Endereço
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal de Associação -->
|
||||||
|
{#if mostrarModalAssociacao}
|
||||||
|
<div class="modal modal-open">
|
||||||
|
<div class="modal-box max-w-2xl">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="text-2xl font-bold">
|
||||||
|
{editandoAssociacao ? 'Editar Associação' : 'Associar Endereço'}
|
||||||
|
</h2>
|
||||||
|
<button class="btn btn-sm btn-circle btn-ghost" onclick={limparFormulario}>
|
||||||
|
<X class="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Endereço -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-medium">Endereço *</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
bind:value={enderecoSelecionado}
|
||||||
|
class="select select-bordered"
|
||||||
|
disabled={!!editandoAssociacao}
|
||||||
|
>
|
||||||
|
<option value="">Selecione um endereço</option>
|
||||||
|
{#if editandoAssociacao}
|
||||||
|
<!-- Ao editar, mostrar apenas o endereço atual -->
|
||||||
|
<option value={editandoAssociacao.endereco._id}>
|
||||||
|
{editandoAssociacao.endereco.nome} - {editandoAssociacao.endereco.endereco}, {editandoAssociacao.endereco.cidade}/{editandoAssociacao.endereco.estado}
|
||||||
|
(raio padrão: {editandoAssociacao.endereco.raioMetros}m)
|
||||||
|
</option>
|
||||||
|
{:else}
|
||||||
|
<!-- Ao criar, mostrar apenas endereços disponíveis para associar -->
|
||||||
|
{#each enderecosParaAssociar as endereco (endereco._id)}
|
||||||
|
<option value={endereco._id}>
|
||||||
|
{endereco.nome} - {endereco.endereco}, {endereco.cidade}/{endereco.estado}
|
||||||
|
(raio padrão: {endereco.raioMetros}m)
|
||||||
|
</option>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</select>
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text-alt">
|
||||||
|
{#if editandoAssociacao}
|
||||||
|
Não é possível alterar o endereço. Crie uma nova associação se necessário.
|
||||||
|
{:else}
|
||||||
|
Selecione um endereço que ainda não está associado a este funcionário
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Raio Personalizado -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-medium">Raio Personalizado (metros)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
bind:value={raioPersonalizado}
|
||||||
|
min="0"
|
||||||
|
max="50000"
|
||||||
|
placeholder="Deixe vazio para usar o raio padrão do endereço"
|
||||||
|
class="input input-bordered"
|
||||||
|
/>
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text-alt">
|
||||||
|
Opcional. Se informado, sobrescreve o raio padrão do endereço para este
|
||||||
|
funcionário.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Período de Validade -->
|
||||||
|
<div class="divider">Período de Validade (opcional)</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-medium">Data Início</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
bind:value={dataInicio}
|
||||||
|
class="input input-bordered"
|
||||||
|
/>
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text-alt">Para deslocamentos temporários</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-medium">Data Fim</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
bind:value={dataFim}
|
||||||
|
class="input input-bordered"
|
||||||
|
/>
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text-alt">Para deslocamentos temporários</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-action">
|
||||||
|
<button class="btn btn-ghost" onclick={limparFormulario} disabled={processando}>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary" onclick={salvarAssociacao} disabled={processando}>
|
||||||
|
{#if processando}
|
||||||
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
|
{:else}
|
||||||
|
Salvar
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Barra de Busca -->
|
||||||
|
<div class="card bg-base-100 shadow-xl mb-6">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="form-control">
|
||||||
|
<div class="relative">
|
||||||
|
<Search
|
||||||
|
class="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-base-content/50"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={termoBusca}
|
||||||
|
placeholder="Buscar por nome, endereço, cidade ou tipo..."
|
||||||
|
class="input input-bordered pl-10 w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Aviso sobre endereços tipo "sede" -->
|
||||||
|
{#if associacoes.length === 0}
|
||||||
|
<div class="alert alert-info mb-6">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
class="stroke-current shrink-0 h-6 w-6"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
<span>
|
||||||
|
Este funcionário não possui endereços específicos associados. O sistema usará
|
||||||
|
automaticamente os endereços tipo "Sede Principal" configurados globalmente.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Lista de Associações -->
|
||||||
|
<div class="grid grid-cols-1 gap-4">
|
||||||
|
{#if associacoesFiltradas.length === 0}
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body text-center py-12">
|
||||||
|
<MapPin class="h-16 w-16 text-base-content/20 mx-auto mb-4" />
|
||||||
|
<p class="text-lg text-base-content/60">
|
||||||
|
{termoBusca
|
||||||
|
? 'Nenhuma associação encontrada'
|
||||||
|
: 'Nenhum endereço associado'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#each associacoesFiltradas as associacao (associacao._id)}
|
||||||
|
{@const raioUsado = associacao.raioMetros}
|
||||||
|
<div
|
||||||
|
class="card bg-base-100 shadow-xl {associacao.ativo ? '' : 'opacity-60'}"
|
||||||
|
>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
<h3 class="text-xl font-bold">{associacao.endereco.nome}</h3>
|
||||||
|
<span
|
||||||
|
class="badge {associacao.ativo ? 'badge-success' : 'badge-error'}"
|
||||||
|
>
|
||||||
|
{associacao.ativo ? 'Ativa' : 'Inativa'}
|
||||||
|
</span>
|
||||||
|
<span class="badge badge-outline">
|
||||||
|
{tiposLabel[associacao.endereco.tipo] || associacao.endereco.tipo}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1 text-sm">
|
||||||
|
<p class="text-base-content/80">
|
||||||
|
<strong>Endereço:</strong> {associacao.endereco.endereco}
|
||||||
|
</p>
|
||||||
|
<p class="text-base-content/80">
|
||||||
|
<strong>Cidade:</strong> {associacao.endereco.cidade}/
|
||||||
|
{associacao.endereco.estado}
|
||||||
|
</p>
|
||||||
|
<p class="text-base-content/80">
|
||||||
|
<strong>Raio Permitido:</strong>
|
||||||
|
{#if raioUsado >= 1000}
|
||||||
|
{@const raioKm = (raioUsado / 1000).toFixed(2)}
|
||||||
|
{raioKm} km
|
||||||
|
{:else}
|
||||||
|
{raioUsado} metros
|
||||||
|
{/if}
|
||||||
|
{#if associacao.raioMetrosPersonalizado !== null &&
|
||||||
|
associacao.raioMetrosPersonalizado !== undefined}
|
||||||
|
<span class="badge badge-sm badge-primary ml-2">
|
||||||
|
Personalizado
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span class="badge badge-sm badge-outline ml-2">
|
||||||
|
Padrão ({associacao.endereco.raioMetros}m)
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
{#if associacao.dataInicio || associacao.dataFim}
|
||||||
|
<p class="text-base-content/80">
|
||||||
|
<strong>Período:</strong>
|
||||||
|
{#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}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-outline btn-primary gap-2"
|
||||||
|
onclick={() => abrirFormularioEdicao(associacao)}
|
||||||
|
>
|
||||||
|
<Edit class="h-4 w-4" />
|
||||||
|
Editar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-outline btn-error gap-2"
|
||||||
|
onclick={() => removerAssociacao(associacao._id)}
|
||||||
|
>
|
||||||
|
<Trash2 class="h-4 w-4" />
|
||||||
|
Remover
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
@@ -74,20 +74,8 @@
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Inicializar gráfico
|
// Função para criar/atualizar o gráfico
|
||||||
$effect(() => {
|
function criarGrafico() {
|
||||||
if (!chartCanvas || !estatisticas || !chartData) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Destruir gráfico anterior se existir
|
|
||||||
if (chartInstance) {
|
|
||||||
chartInstance.destroy();
|
|
||||||
chartInstance = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Aguardar um pouco para garantir que o canvas está renderizado
|
|
||||||
const timeoutId = setTimeout(() => {
|
|
||||||
if (!chartCanvas || !estatisticas || !chartData) {
|
if (!chartCanvas || !estatisticas || !chartData) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -97,6 +85,12 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Destruir gráfico anterior se existir
|
||||||
|
if (chartInstance) {
|
||||||
|
chartInstance.destroy();
|
||||||
|
chartInstance = null;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
chartInstance = new Chart(ctx, {
|
chartInstance = new Chart(ctx, {
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
@@ -177,15 +171,29 @@
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erro ao criar gráfico:', error);
|
console.error('Erro ao criar gráfico:', error);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inicializar gráfico quando canvas e dados estiverem disponíveis
|
||||||
|
$effect(() => {
|
||||||
|
if (chartCanvas && estatisticas && chartData) {
|
||||||
|
// Aguardar um pouco para garantir que o canvas está renderizado
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
criarGrafico();
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
if (chartInstance) {
|
|
||||||
chartInstance.destroy();
|
|
||||||
chartInstance = null;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Também tentar criar quando o canvas for montado
|
||||||
|
onMount(() => {
|
||||||
|
if (chartCanvas && estatisticas && chartData) {
|
||||||
|
setTimeout(() => {
|
||||||
|
criarGrafico();
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
@@ -1016,6 +1024,357 @@
|
|||||||
yPosition += 5;
|
yPosition += 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validação de GPS e Anti-Spoofing
|
||||||
|
if (registro.latitude && registro.longitude) {
|
||||||
|
// Verificar se precisa de nova página
|
||||||
|
if (yPosition > 200) {
|
||||||
|
doc.addPage();
|
||||||
|
yPosition = 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setFontSize(12);
|
||||||
|
doc.text('VALIDAÇÃO DE LOCALIZAÇÃO GPS', 15, yPosition);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setFontSize(10);
|
||||||
|
yPosition += 10;
|
||||||
|
|
||||||
|
// Informações detalhadas do GPS
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text('Dados do GPS:', 15, yPosition);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
yPosition += 6;
|
||||||
|
|
||||||
|
if (registro.precisao !== null && registro.precisao !== undefined) {
|
||||||
|
doc.text(` Precisão: ${registro.precisao.toFixed(2)} metros`, 20, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registro.altitude !== null && registro.altitude !== undefined) {
|
||||||
|
doc.text(` Altitude: ${registro.altitude.toFixed(2)} metros`, 20, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registro.altitudeAccuracy !== null && registro.altitudeAccuracy !== undefined) {
|
||||||
|
doc.text(` Precisão da Altitude: ${registro.altitudeAccuracy.toFixed(2)} metros`, 20, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registro.heading !== null && registro.heading !== undefined) {
|
||||||
|
doc.text(` Direção (Heading): ${registro.heading.toFixed(2)}°`, 20, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registro.speed !== null && registro.speed !== undefined) {
|
||||||
|
doc.text(` Velocidade: ${(registro.speed * 3.6).toFixed(2)} km/h`, 20, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
yPosition += 3;
|
||||||
|
|
||||||
|
// Confiabilidade e Scores
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text('Confiabilidade:', 15, yPosition);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
yPosition += 6;
|
||||||
|
|
||||||
|
if (registro.confiabilidadeGPS !== null && registro.confiabilidadeGPS !== undefined) {
|
||||||
|
const confiabilidadePercent = (registro.confiabilidadeGPS * 100).toFixed(1);
|
||||||
|
const confiabilidadeCor = registro.confiabilidadeGPS >= 0.7 ? [0, 128, 0] : registro.confiabilidadeGPS >= 0.4 ? [255, 165, 0] : [255, 0, 0];
|
||||||
|
doc.setTextColor(confiabilidadeCor[0], confiabilidadeCor[1], confiabilidadeCor[2]);
|
||||||
|
doc.text(` Confiabilidade GPS (Frontend): ${confiabilidadePercent}%`, 20, yPosition);
|
||||||
|
doc.setTextColor(0, 0, 0);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registro.scoreConfiancaBackend !== null && registro.scoreConfiancaBackend !== undefined) {
|
||||||
|
const scorePercent = (registro.scoreConfiancaBackend * 100).toFixed(1);
|
||||||
|
const scoreCor = registro.scoreConfiancaBackend >= 0.7 ? [0, 128, 0] : registro.scoreConfiancaBackend >= 0.4 ? [255, 165, 0] : [255, 0, 0];
|
||||||
|
doc.setTextColor(scoreCor[0], scoreCor[1], scoreCor[2]);
|
||||||
|
doc.text(` Score de Confiança (Backend): ${scorePercent}%`, 20, yPosition);
|
||||||
|
doc.setTextColor(0, 0, 0);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
yPosition += 3;
|
||||||
|
|
||||||
|
// Status de Validação
|
||||||
|
if (registro.suspeitaSpoofing !== null && registro.suspeitaSpoofing !== undefined) {
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text('Status de Validação:', 15, yPosition);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
yPosition += 6;
|
||||||
|
|
||||||
|
if (registro.suspeitaSpoofing) {
|
||||||
|
doc.setTextColor(255, 0, 0);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text(' ⚠️ MARCAÇÃO SUSPEITA DETECTADA', 20, yPosition);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setTextColor(0, 0, 0);
|
||||||
|
yPosition += 6;
|
||||||
|
} else {
|
||||||
|
doc.setTextColor(0, 128, 0);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text(' ✓ Localização validada com sucesso', 20, yPosition);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setTextColor(0, 0, 0);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registro.motivoSuspeita) {
|
||||||
|
doc.setTextColor(255, 0, 0);
|
||||||
|
const motivoLines = doc.splitTextToSize(` Motivo: ${registro.motivoSuspeita}`, 170);
|
||||||
|
doc.text(motivoLines, 20, yPosition);
|
||||||
|
yPosition += motivoLines.length * 5;
|
||||||
|
doc.setTextColor(0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
yPosition += 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Avisos de Validação
|
||||||
|
if (registro.avisosValidacao && registro.avisosValidacao.length > 0) {
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text('Avisos de Validação:', 15, yPosition);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
yPosition += 6;
|
||||||
|
|
||||||
|
registro.avisosValidacao.forEach((aviso: string) => {
|
||||||
|
const avisoLines = doc.splitTextToSize(` • ${aviso}`, 170);
|
||||||
|
doc.text(avisoLines, 20, yPosition);
|
||||||
|
yPosition += avisoLines.length * 5;
|
||||||
|
});
|
||||||
|
|
||||||
|
yPosition += 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Análise de Propriedades GPS
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text('Análise de Propriedades GPS:', 15, yPosition);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
yPosition += 6;
|
||||||
|
|
||||||
|
let propriedadesGPS = 0;
|
||||||
|
let propriedadesTotais = 5;
|
||||||
|
|
||||||
|
if (registro.altitude !== null && registro.altitude !== undefined && registro.altitude !== 0) {
|
||||||
|
doc.text(' ✓ Altitude disponível', 20, yPosition);
|
||||||
|
propriedadesGPS++;
|
||||||
|
} else {
|
||||||
|
doc.text(' ✗ Altitude não disponível', 20, yPosition);
|
||||||
|
}
|
||||||
|
yPosition += 5;
|
||||||
|
|
||||||
|
if (registro.altitudeAccuracy !== null && registro.altitudeAccuracy !== undefined && registro.altitudeAccuracy > 0) {
|
||||||
|
doc.text(' ✓ Precisão de altitude disponível', 20, yPosition);
|
||||||
|
propriedadesGPS++;
|
||||||
|
} else {
|
||||||
|
doc.text(' ✗ Precisão de altitude não disponível', 20, yPosition);
|
||||||
|
}
|
||||||
|
yPosition += 5;
|
||||||
|
|
||||||
|
if (registro.heading !== null && registro.heading !== undefined && !isNaN(registro.heading)) {
|
||||||
|
doc.text(' ✓ Direção (heading) disponível', 20, yPosition);
|
||||||
|
propriedadesGPS++;
|
||||||
|
} else {
|
||||||
|
doc.text(' ✗ Direção (heading) não disponível', 20, yPosition);
|
||||||
|
}
|
||||||
|
yPosition += 5;
|
||||||
|
|
||||||
|
if (registro.speed !== null && registro.speed !== undefined && !isNaN(registro.speed)) {
|
||||||
|
doc.text(' ✓ Velocidade disponível', 20, yPosition);
|
||||||
|
propriedadesGPS++;
|
||||||
|
} else {
|
||||||
|
doc.text(' ✗ Velocidade não disponível', 20, yPosition);
|
||||||
|
}
|
||||||
|
yPosition += 5;
|
||||||
|
|
||||||
|
if (registro.precisao !== null && registro.precisao !== undefined && registro.precisao < 20) {
|
||||||
|
doc.text(' ✓ Alta precisão GPS (< 20m)', 20, yPosition);
|
||||||
|
propriedadesGPS++;
|
||||||
|
} else if (registro.precisao !== null && registro.precisao !== undefined && registro.precisao >= 20 && registro.precisao < 100) {
|
||||||
|
doc.text(' ⚠ Precisão média GPS (20-100m)', 20, yPosition);
|
||||||
|
propriedadesGPS += 0.5;
|
||||||
|
} else {
|
||||||
|
doc.text(' ✗ Baixa precisão GPS (> 100m)', 20, yPosition);
|
||||||
|
}
|
||||||
|
yPosition += 5;
|
||||||
|
|
||||||
|
// Indicador de qualidade GPS
|
||||||
|
const qualidadeGPS = (propriedadesGPS / propriedadesTotais) * 100;
|
||||||
|
const qualidadeTexto = qualidadeGPS >= 80 ? 'Alta qualidade (GPS real)' : qualidadeGPS >= 50 ? 'Qualidade média' : 'Baixa qualidade (possível spoofing)';
|
||||||
|
const qualidadeCor = qualidadeGPS >= 80 ? [0, 128, 0] : qualidadeGPS >= 50 ? [255, 165, 0] : [255, 0, 0];
|
||||||
|
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setTextColor(qualidadeCor[0], qualidadeCor[1], qualidadeCor[2]);
|
||||||
|
doc.text(`Qualidade GPS: ${qualidadeTexto} (${qualidadeGPS.toFixed(0)}% das propriedades)`, 20, yPosition);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setTextColor(0, 0, 0);
|
||||||
|
yPosition += 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validação de Geofencing (Localização Permitida)
|
||||||
|
if (registro.latitude && registro.longitude) {
|
||||||
|
// Verificar se precisa de nova página
|
||||||
|
if (yPosition > 200) {
|
||||||
|
doc.addPage();
|
||||||
|
yPosition = 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setFontSize(12);
|
||||||
|
doc.text('VALIDAÇÃO DE LOCALIZAÇÃO PERMITIDA', 15, yPosition);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setFontSize(10);
|
||||||
|
yPosition += 10;
|
||||||
|
|
||||||
|
if (registro.enderecoMarcacaoEsperado || registro.dentroRaioPermitido !== undefined) {
|
||||||
|
// Buscar dados do endereço esperado se houver ID
|
||||||
|
let enderecoEsperadoNome = 'Não configurado';
|
||||||
|
let enderecoEsperadoEndereco = 'Não configurado';
|
||||||
|
let enderecoEsperadoLatitude: number | null = null;
|
||||||
|
let enderecoEsperadoLongitude: number | null = null;
|
||||||
|
|
||||||
|
if (registro.enderecoMarcacaoEsperado) {
|
||||||
|
try {
|
||||||
|
const enderecoEsperado = await client.query(
|
||||||
|
api.enderecosMarcacao.obterEndereco,
|
||||||
|
{ enderecoId: registro.enderecoMarcacaoEsperado }
|
||||||
|
);
|
||||||
|
if (enderecoEsperado) {
|
||||||
|
enderecoEsperadoNome = enderecoEsperado.nome;
|
||||||
|
enderecoEsperadoEndereco = `${enderecoEsperado.endereco}, ${enderecoEsperado.cidade}/${enderecoEsperado.estado}`;
|
||||||
|
enderecoEsperadoLatitude = enderecoEsperado.latitude;
|
||||||
|
enderecoEsperadoLongitude = enderecoEsperado.longitude;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Erro ao buscar endereço esperado:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text('Endereço Esperado:', 15, yPosition);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
yPosition += 6;
|
||||||
|
|
||||||
|
doc.text(` Nome: ${enderecoEsperadoNome}`, 20, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
|
||||||
|
const enderecoLines = doc.splitTextToSize(` Endereço: ${enderecoEsperadoEndereco}`, 170);
|
||||||
|
doc.text(enderecoLines, 20, yPosition);
|
||||||
|
yPosition += enderecoLines.length * 5 + 3;
|
||||||
|
|
||||||
|
if (enderecoEsperadoLatitude !== null && enderecoEsperadoLongitude !== null) {
|
||||||
|
doc.text(` Coordenadas: ${enderecoEsperadoLatitude.toFixed(6)}, ${enderecoEsperadoLongitude.toFixed(6)}`, 20, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
yPosition += 3;
|
||||||
|
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text('Localização do Registro:', 15, yPosition);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
yPosition += 6;
|
||||||
|
|
||||||
|
doc.text(` Coordenadas: ${registro.latitude.toFixed(6)}, ${registro.longitude.toFixed(6)}`, 20, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
|
||||||
|
if (registro.distanciaEnderecoEsperado !== null && registro.distanciaEnderecoEsperado !== undefined) {
|
||||||
|
const distanciaKm = (registro.distanciaEnderecoEsperado / 1000).toFixed(2);
|
||||||
|
const distanciaMetros = registro.distanciaEnderecoEsperado.toFixed(0);
|
||||||
|
|
||||||
|
if (registro.distanciaEnderecoEsperado >= 1000) {
|
||||||
|
doc.text(` Distância: ${distanciaKm} km (${distanciaMetros} metros)`, 20, yPosition);
|
||||||
|
} else {
|
||||||
|
doc.text(` Distância: ${distanciaMetros} metros`, 20, yPosition);
|
||||||
|
}
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
yPosition += 3;
|
||||||
|
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text('Raio Permitido:', 15, yPosition);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
yPosition += 6;
|
||||||
|
|
||||||
|
if (registro.raioToleranciaUsado !== null && registro.raioToleranciaUsado !== undefined) {
|
||||||
|
const raioKm = (registro.raioToleranciaUsado / 1000).toFixed(2);
|
||||||
|
const raioMetros = registro.raioToleranciaUsado.toFixed(0);
|
||||||
|
|
||||||
|
if (registro.raioToleranciaUsado >= 1000) {
|
||||||
|
doc.text(` ${raioKm} km (${raioMetros} metros)`, 20, yPosition);
|
||||||
|
} else {
|
||||||
|
doc.text(` ${raioMetros} metros`, 20, yPosition);
|
||||||
|
}
|
||||||
|
yPosition += 6;
|
||||||
|
} else {
|
||||||
|
doc.text(' Não configurado', 20, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
yPosition += 3;
|
||||||
|
|
||||||
|
// Status da validação
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text('Status:', 15, yPosition);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
yPosition += 6;
|
||||||
|
|
||||||
|
if (registro.dentroRaioPermitido === true) {
|
||||||
|
doc.setTextColor(0, 128, 0);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text(' ✓ DENTRO DO RAIO PERMITIDO', 20, yPosition);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setTextColor(0, 0, 0);
|
||||||
|
} else if (registro.dentroRaioPermitido === false) {
|
||||||
|
doc.setTextColor(255, 0, 0);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text(' ⚠️ FORA DO RAIO PERMITIDO', 20, yPosition);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setTextColor(0, 0, 0);
|
||||||
|
yPosition += 6;
|
||||||
|
|
||||||
|
if (
|
||||||
|
registro.distanciaEnderecoEsperado !== null &&
|
||||||
|
registro.distanciaEnderecoEsperado !== undefined &&
|
||||||
|
registro.raioToleranciaUsado !== null &&
|
||||||
|
registro.raioToleranciaUsado !== undefined
|
||||||
|
) {
|
||||||
|
const distanciaExcedente = registro.distanciaEnderecoEsperado - registro.raioToleranciaUsado;
|
||||||
|
const distanciaExcedenteKm = (distanciaExcedente / 1000).toFixed(2);
|
||||||
|
const distanciaExcedenteMetros = distanciaExcedente.toFixed(0);
|
||||||
|
|
||||||
|
if (distanciaExcedente >= 1000) {
|
||||||
|
doc.text(` ${distanciaExcedenteKm} km além do permitido`, 20, yPosition);
|
||||||
|
} else {
|
||||||
|
doc.text(` ${distanciaExcedenteMetros} metros além do permitido`, 20, yPosition);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
yPosition += 6;
|
||||||
|
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setTextColor(0, 0, 0);
|
||||||
|
doc.setFontSize(9);
|
||||||
|
const observacaoLines = doc.splitTextToSize(
|
||||||
|
'O registro foi realizado fora da área permitida de marcação de ponto. Verifique se o funcionário possui autorização para trabalho remoto ou deslocamento.',
|
||||||
|
170
|
||||||
|
);
|
||||||
|
doc.text(observacaoLines, 20, yPosition);
|
||||||
|
yPosition += observacaoLines.length * 4;
|
||||||
|
doc.setFontSize(10);
|
||||||
|
} else {
|
||||||
|
doc.text(' Não validado', 20, yPosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
yPosition += 8;
|
||||||
|
} else {
|
||||||
|
doc.text('Validação de localização permitida não configurada para este registro.', 15, yPosition);
|
||||||
|
yPosition += 8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Dados Técnicos
|
// Dados Técnicos
|
||||||
// Verificar se precisa de nova página
|
// Verificar se precisa de nova página
|
||||||
if (yPosition > 200) {
|
if (yPosition > 200) {
|
||||||
@@ -1267,65 +1626,122 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="container mx-auto px-4 py-6">
|
<div class="container mx-auto px-4 py-6 max-w-7xl">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="flex items-center justify-between mb-6">
|
<section class="relative mb-8 overflow-hidden rounded-2xl border border-base-300 bg-gradient-to-br from-primary/10 via-base-100 to-secondary/10 p-8 shadow-lg">
|
||||||
|
<div class="bg-primary/20 absolute top-10 -left-10 h-40 w-40 rounded-full blur-3xl"></div>
|
||||||
|
<div class="bg-secondary/20 absolute right-0 -bottom-16 h-56 w-56 rounded-full blur-3xl"></div>
|
||||||
|
<div class="relative z-10 flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<div class="p-3 bg-primary/10 rounded-xl">
|
<div class="p-4 bg-primary/20 rounded-2xl backdrop-blur-sm border border-primary/30 shadow-lg">
|
||||||
<Clock class="h-8 w-8 text-primary" strokeWidth={2} />
|
<Clock class="h-10 w-10 text-primary" strokeWidth={2.5} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="max-w-3xl space-y-2">
|
||||||
<h1 class="text-3xl font-bold text-base-content">Registro de Pontos</h1>
|
<h1 class="text-4xl font-black text-base-content leading-tight sm:text-5xl">
|
||||||
<p class="text-base-content/60 mt-1">Gerencie e visualize os registros de ponto dos funcionários</p>
|
Registro de Pontos
|
||||||
|
</h1>
|
||||||
|
<p class="text-base-content/70 text-base leading-relaxed sm:text-lg">
|
||||||
|
Gerencie e visualize os registros de ponto dos funcionários com informações detalhadas e relatórios
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Estatísticas -->
|
|
||||||
{#if estatisticas}
|
{#if estatisticas}
|
||||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
<div class="border-base-200/60 bg-base-100/70 grid grid-cols-2 gap-4 rounded-2xl border p-6 shadow-lg backdrop-blur sm:max-w-sm">
|
||||||
<div class="stat bg-base-100 shadow-lg rounded-lg">
|
<div>
|
||||||
<div class="stat-figure text-primary">
|
<p class="text-base-content/60 text-sm font-semibold">Total de Registros</p>
|
||||||
<BarChart3 class="h-8 w-8" />
|
<p class="text-base-content mt-2 text-2xl font-bold">{estatisticas.totalRegistros}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-title">Total de Registros</div>
|
<div class="text-right">
|
||||||
<div class="stat-value text-primary">{estatisticas.totalRegistros}</div>
|
<p class="text-base-content/60 text-sm font-semibold">Funcionários</p>
|
||||||
|
<p class="text-base-content mt-2 text-xl font-bold">{estatisticas.totalFuncionarios}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="via-base-300 col-span-2 h-px bg-gradient-to-r from-transparent to-transparent"></div>
|
||||||
<div class="stat bg-base-100 shadow-lg rounded-lg">
|
<div class="text-base-content/70 col-span-2 flex items-center justify-between text-sm">
|
||||||
<div class="stat-figure text-success">
|
<span>
|
||||||
<CheckCircle2 class="h-8 w-8" />
|
|
||||||
</div>
|
|
||||||
<div class="stat-title">Dentro do Prazo</div>
|
|
||||||
<div class="stat-value text-success">{estatisticas.dentroDoPrazo}</div>
|
|
||||||
<div class="stat-desc">
|
|
||||||
{estatisticas.totalRegistros > 0
|
{estatisticas.totalRegistros > 0
|
||||||
? ((estatisticas.dentroDoPrazo / estatisticas.totalRegistros) * 100).toFixed(1)
|
? ((estatisticas.dentroDoPrazo / estatisticas.totalRegistros) * 100).toFixed(1)
|
||||||
: 0}%
|
: 0}% dentro do prazo
|
||||||
|
</span>
|
||||||
|
<span class="badge badge-primary badge-sm">Ativo</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Cards de Estatísticas -->
|
||||||
|
{#if estatisticas}
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||||
|
<!-- Total de Registros -->
|
||||||
|
<div class="card bg-gradient-to-br from-blue-500/10 to-blue-600/20 border border-blue-500/20 shadow-xl hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-1">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-sm text-base-content/70 font-semibold mb-1">Total de Registros</p>
|
||||||
|
<p class="text-3xl font-bold text-base-content">{estatisticas.totalRegistros}</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 bg-blue-500/20 rounded-xl">
|
||||||
|
<BarChart3 class="h-8 w-8 text-blue-600" strokeWidth={2.5} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stat bg-base-100 shadow-lg rounded-lg">
|
<!-- Dentro do Prazo -->
|
||||||
<div class="stat-figure text-error">
|
<div class="card bg-gradient-to-br from-green-500/10 to-green-600/20 border border-green-500/20 shadow-xl hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-1">
|
||||||
<XCircle class="h-8 w-8" />
|
<div class="card-body">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-sm text-base-content/70 font-semibold mb-1">Dentro do Prazo</p>
|
||||||
|
<p class="text-3xl font-bold text-green-600">{estatisticas.dentroDoPrazo}</p>
|
||||||
|
<p class="text-xs text-base-content/60 mt-1">
|
||||||
|
{estatisticas.totalRegistros > 0
|
||||||
|
? ((estatisticas.dentroDoPrazo / estatisticas.totalRegistros) * 100).toFixed(1)
|
||||||
|
: 0}% do total
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-title">Fora do Prazo</div>
|
<div class="p-3 bg-green-500/20 rounded-xl">
|
||||||
<div class="stat-value text-error">{estatisticas.foraDoPrazo}</div>
|
<CheckCircle2 class="h-8 w-8 text-green-600" strokeWidth={2.5} />
|
||||||
<div class="stat-desc">
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fora do Prazo -->
|
||||||
|
<div class="card bg-gradient-to-br from-red-500/10 to-red-600/20 border border-red-500/20 shadow-xl hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-1">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-sm text-base-content/70 font-semibold mb-1">Fora do Prazo</p>
|
||||||
|
<p class="text-3xl font-bold text-red-600">{estatisticas.foraDoPrazo}</p>
|
||||||
|
<p class="text-xs text-base-content/60 mt-1">
|
||||||
{estatisticas.totalRegistros > 0
|
{estatisticas.totalRegistros > 0
|
||||||
? ((estatisticas.foraDoPrazo / estatisticas.totalRegistros) * 100).toFixed(1)
|
? ((estatisticas.foraDoPrazo / estatisticas.totalRegistros) * 100).toFixed(1)
|
||||||
: 0}%
|
: 0}% do total
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 bg-red-500/20 rounded-xl">
|
||||||
|
<XCircle class="h-8 w-8 text-red-600" strokeWidth={2.5} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stat bg-base-100 shadow-lg rounded-lg">
|
<!-- Funcionários -->
|
||||||
<div class="stat-figure text-info">
|
<div class="card bg-gradient-to-br from-purple-500/10 to-purple-600/20 border border-purple-500/20 shadow-xl hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-1">
|
||||||
<Users class="h-8 w-8" />
|
<div class="card-body">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-sm text-base-content/70 font-semibold mb-1">Funcionários</p>
|
||||||
|
<p class="text-3xl font-bold text-purple-600">{estatisticas.totalFuncionarios}</p>
|
||||||
|
<p class="text-xs text-base-content/60 mt-1">
|
||||||
|
{estatisticas.funcionariosDentroPrazo} dentro, {estatisticas.funcionariosForaPrazo} fora
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 bg-purple-500/20 rounded-xl">
|
||||||
|
<Users class="h-8 w-8 text-purple-600" strokeWidth={2.5} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-title">Funcionários</div>
|
|
||||||
<div class="stat-value text-info">{estatisticas.totalFuncionarios}</div>
|
|
||||||
<div class="stat-desc">
|
|
||||||
{estatisticas.funcionariosDentroPrazo} dentro do prazo, {estatisticas.funcionariosForaPrazo} fora
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1333,13 +1749,17 @@
|
|||||||
|
|
||||||
<!-- Gráfico de Estatísticas -->
|
<!-- Gráfico de Estatísticas -->
|
||||||
{#if estatisticas}
|
{#if estatisticas}
|
||||||
<div class="card bg-base-100 shadow-xl mb-6">
|
<div class="card bg-base-100/90 backdrop-blur-sm border border-base-300 shadow-xl mb-8">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title mb-4">
|
<div class="flex items-center justify-between mb-6">
|
||||||
<BarChart3 class="h-5 w-5" />
|
<h2 class="card-title text-2xl">
|
||||||
Visão Geral das Estatísticas
|
<div class="p-2 bg-primary/10 rounded-lg">
|
||||||
|
<BarChart3 class="h-6 w-6 text-primary" strokeWidth={2.5} />
|
||||||
|
</div>
|
||||||
|
<span>Visão Geral das Estatísticas</span>
|
||||||
</h2>
|
</h2>
|
||||||
<div class="h-80 w-full relative">
|
</div>
|
||||||
|
<div class="h-80 w-full relative rounded-xl bg-base-200/50 p-4 border border-base-300">
|
||||||
<canvas bind:this={chartCanvas} class="w-full h-full"></canvas>
|
<canvas bind:this={chartCanvas} class="w-full h-full"></canvas>
|
||||||
{#if !chartInstance && estatisticas}
|
{#if !chartInstance && estatisticas}
|
||||||
<div class="absolute inset-0 flex items-center justify-center">
|
<div class="absolute inset-0 flex items-center justify-center">
|
||||||
@@ -1347,83 +1767,54 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 grid grid-cols-2 md:grid-cols-4 gap-4 text-center">
|
|
||||||
<div class="stat bg-base-200 rounded-lg p-4">
|
|
||||||
<div class="stat-title text-xs">Total</div>
|
|
||||||
<div class="stat-value text-primary text-2xl">{estatisticas.totalRegistros}</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat bg-success/10 rounded-lg p-4">
|
|
||||||
<div class="stat-title text-xs">Dentro do Prazo</div>
|
|
||||||
<div class="stat-value text-success text-2xl">{estatisticas.dentroDoPrazo}</div>
|
|
||||||
<div class="stat-desc text-success">
|
|
||||||
{estatisticas.totalRegistros > 0
|
|
||||||
? ((estatisticas.dentroDoPrazo / estatisticas.totalRegistros) * 100).toFixed(1)
|
|
||||||
: 0}%
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat bg-error/10 rounded-lg p-4">
|
|
||||||
<div class="stat-title text-xs">Fora do Prazo</div>
|
|
||||||
<div class="stat-value text-error text-2xl">{estatisticas.foraDoPrazo}</div>
|
|
||||||
<div class="stat-desc text-error">
|
|
||||||
{estatisticas.totalRegistros > 0
|
|
||||||
? ((estatisticas.foraDoPrazo / estatisticas.totalRegistros) * 100).toFixed(1)
|
|
||||||
: 0}%
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat bg-info/10 rounded-lg p-4">
|
|
||||||
<div class="stat-title text-xs">Funcionários</div>
|
|
||||||
<div class="stat-value text-info text-2xl">{estatisticas.totalFuncionarios}</div>
|
|
||||||
<div class="stat-desc text-info">
|
|
||||||
{estatisticas.funcionariosDentroPrazo} dentro, {estatisticas.funcionariosForaPrazo} fora
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Filtros -->
|
<!-- Filtros -->
|
||||||
<div class="card bg-base-100 shadow-xl mb-6">
|
<div class="card bg-base-100/90 backdrop-blur-sm border border-base-300 shadow-xl mb-8">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title mb-4">
|
<div class="flex items-center gap-3 mb-6">
|
||||||
<Filter class="h-5 w-5" />
|
<div class="p-2 bg-secondary/10 rounded-lg">
|
||||||
Filtros
|
<Filter class="h-5 w-5 text-secondary" strokeWidth={2.5} />
|
||||||
</h2>
|
</div>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
<h2 class="card-title text-2xl mb-0">Filtros de Busca</h2>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label" for="data-inicio">
|
<label class="label" for="data-inicio">
|
||||||
<span class="label-text font-medium">Data Início</span>
|
<span class="label-text font-semibold">Data Início</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="data-inicio"
|
id="data-inicio"
|
||||||
type="date"
|
type="date"
|
||||||
bind:value={dataInicio}
|
bind:value={dataInicio}
|
||||||
class="input input-bordered"
|
class="input input-bordered input-primary focus:input-primary focus:ring-2 focus:ring-primary/20"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label" for="data-fim">
|
<label class="label" for="data-fim">
|
||||||
<span class="label-text font-medium">Data Fim</span>
|
<span class="label-text font-semibold">Data Fim</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="data-fim"
|
id="data-fim"
|
||||||
type="date"
|
type="date"
|
||||||
bind:value={dataFim}
|
bind:value={dataFim}
|
||||||
class="input input-bordered"
|
class="input input-bordered input-primary focus:input-primary focus:ring-2 focus:ring-primary/20"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label" for="funcionario">
|
<label class="label" for="funcionario">
|
||||||
<span class="label-text font-medium">Funcionário</span>
|
<span class="label-text font-semibold">Funcionário</span>
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
id="funcionario"
|
id="funcionario"
|
||||||
bind:value={funcionarioIdFiltro}
|
bind:value={funcionarioIdFiltro}
|
||||||
class="select select-bordered"
|
class="select select-bordered select-primary focus:select-primary focus:ring-2 focus:ring-primary/20"
|
||||||
>
|
>
|
||||||
<option value="">Todos</option>
|
<option value="">Todos os funcionários</option>
|
||||||
{#each funcionarios as funcionario}
|
{#each funcionarios as funcionario}
|
||||||
<option value={funcionario._id}>{funcionario.nome}</option>
|
<option value={funcionario._id}>{funcionario.nome}</option>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -1434,29 +1825,34 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Lista de Registros -->
|
<!-- Lista de Registros -->
|
||||||
<div class="card bg-base-100 shadow-xl">
|
<div class="card bg-base-100/90 backdrop-blur-sm border border-base-300 shadow-xl">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 mb-6">
|
||||||
<h2 class="card-title">Registros</h2>
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="p-2 bg-primary/10 rounded-lg">
|
||||||
|
<Clock class="h-6 w-6 text-primary" strokeWidth={2.5} />
|
||||||
|
</div>
|
||||||
|
<h2 class="card-title text-2xl mb-0">Registros de Ponto</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Exibição dos Filtros Selecionados -->
|
<!-- Exibição dos Filtros Selecionados -->
|
||||||
{#if funcionarioIdFiltro || dataInicio || dataFim}
|
{#if funcionarioIdFiltro || dataInicio || dataFim}
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
{#if funcionarioIdFiltro && funcionarioSelecionadoNome}
|
{#if funcionarioIdFiltro && funcionarioSelecionadoNome}
|
||||||
<div class="badge badge-primary badge-lg gap-2">
|
<div class="badge badge-primary badge-lg gap-2 px-4 py-3">
|
||||||
<Users class="h-3 w-3" />
|
<Users class="h-4 w-4" />
|
||||||
{funcionarioSelecionadoNome}
|
{funcionarioSelecionadoNome}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if dataInicio}
|
{#if dataInicio}
|
||||||
<div class="badge badge-info badge-lg gap-2">
|
<div class="badge badge-info badge-lg gap-2 px-4 py-3">
|
||||||
<Clock class="h-3 w-3" />
|
<Clock class="h-4 w-4" />
|
||||||
De: {formatarData(dataInicio)}
|
De: {formatarData(dataInicio)}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if dataFim}
|
{#if dataFim}
|
||||||
<div class="badge badge-info badge-lg gap-2">
|
<div class="badge badge-info badge-lg gap-2 px-4 py-3">
|
||||||
<Clock class="h-3 w-3" />
|
<Clock class="h-4 w-4" />
|
||||||
Até: {formatarData(dataFim)}
|
Até: {formatarData(dataFim)}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -1465,53 +1861,76 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if registrosQuery?.status === 'Loading'}
|
{#if registrosQuery?.status === 'Loading'}
|
||||||
<div class="flex items-center justify-center py-12">
|
<div class="flex flex-col items-center justify-center py-16 bg-base-200/50 rounded-xl border border-base-300">
|
||||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
<span class="loading loading-spinner loading-lg text-primary mb-4"></span>
|
||||||
<span class="ml-4 text-base-content/70">Carregando registros...</span>
|
<span class="text-base-content/70 font-medium">Carregando registros...</span>
|
||||||
|
<span class="text-sm text-base-content/50 mt-2">Aguarde um momento</span>
|
||||||
</div>
|
</div>
|
||||||
{:else if registrosQuery?.error}
|
{:else if registrosQuery?.error}
|
||||||
<div class="alert alert-error">
|
<div class="alert alert-error shadow-lg border-2 border-error/50">
|
||||||
<span>Erro ao carregar registros: {registrosQuery.error.message || 'Erro desconhecido'}</span>
|
<XCircle class="h-6 w-6" />
|
||||||
|
<div>
|
||||||
|
<h3 class="font-bold">Erro ao carregar registros</h3>
|
||||||
|
<div class="text-sm mt-1">{registrosQuery.error.message || 'Erro desconhecido'}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else if !registrosQuery?.data}
|
{:else if !registrosQuery?.data}
|
||||||
<div class="alert alert-warning">
|
<div class="alert alert-warning shadow-lg border-2 border-warning/50">
|
||||||
<span>Aguardando dados da consulta...</span>
|
<Clock class="h-6 w-6" />
|
||||||
|
<span class="font-medium">Aguardando dados da consulta...</span>
|
||||||
</div>
|
</div>
|
||||||
{:else if registros.length === 0}
|
{:else if registros.length === 0}
|
||||||
<div class="alert alert-info">
|
<div class="alert alert-info shadow-lg border-2 border-info/50 rounded-xl bg-gradient-to-r from-info/10 to-info/5">
|
||||||
<span>Nenhum registro encontrado para o período selecionado</span>
|
<FileText class="h-6 w-6 text-info" />
|
||||||
<div class="text-sm mt-2 opacity-70">
|
<div class="flex-1">
|
||||||
Período: {formatarData(dataInicio)} até {formatarData(dataFim)}
|
<h3 class="font-bold text-base-content">Nenhum registro encontrado</h3>
|
||||||
|
<div class="text-sm mt-2 opacity-80">
|
||||||
|
<p>Período: <span class="font-semibold">{formatarData(dataInicio)} até {formatarData(dataFim)}</span></p>
|
||||||
{#if funcionarioIdFiltro && funcionarioSelecionadoNome}
|
{#if funcionarioIdFiltro && funcionarioSelecionadoNome}
|
||||||
<br />
|
<p class="mt-1">Funcionário: <span class="font-semibold">{funcionarioSelecionadoNome}</span></p>
|
||||||
Funcionário: {funcionarioSelecionadoNome}
|
|
||||||
{/if}
|
{/if}
|
||||||
|
<p class="mt-2 text-base-content/60">Tente ajustar os filtros para encontrar registros.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else if registrosAgrupados.length === 0}
|
{:else if registrosAgrupados.length === 0}
|
||||||
<div class="alert alert-warning">
|
<div class="alert alert-warning shadow-lg border-2 border-warning/50 rounded-xl bg-gradient-to-r from-warning/10 to-warning/5">
|
||||||
<span>Registros encontrados, mas não foi possível agrupá-los</span>
|
<FileText class="h-6 w-6 text-warning" />
|
||||||
<div class="text-sm mt-2 opacity-70">
|
<div class="flex-1">
|
||||||
Total de registros: {registros.length}
|
<h3 class="font-bold">Registros encontrados, mas não foi possível agrupá-los</h3>
|
||||||
|
<div class="text-sm mt-2 opacity-80">
|
||||||
|
Total de registros: <span class="font-semibold">{registros.length}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-4">
|
<div class="space-y-6">
|
||||||
{#each registrosAgrupados as grupo}
|
{#each registrosAgrupados as grupo}
|
||||||
<div class="card bg-base-200">
|
<div class="card bg-gradient-to-br from-base-100 to-base-200/50 border border-base-300 shadow-lg hover:shadow-xl transition-all duration-300">
|
||||||
<div class="card-body">
|
<div class="card-body p-6">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex flex-col lg:flex-row items-start lg:items-center justify-between gap-4 mb-6">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<h3 class="font-bold text-lg">
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
<div class="p-2 bg-primary/10 rounded-lg">
|
||||||
|
<Users class="h-5 w-5 text-primary" strokeWidth={2.5} />
|
||||||
|
</div>
|
||||||
|
<h3 class="font-bold text-xl text-base-content">
|
||||||
{grupo.funcionario?.nome || 'Funcionário não encontrado'}
|
{grupo.funcionario?.nome || 'Funcionário não encontrado'}
|
||||||
</h3>
|
</h3>
|
||||||
|
</div>
|
||||||
{#if grupo.funcionario?.matricula}
|
{#if grupo.funcionario?.matricula}
|
||||||
<p class="text-sm text-base-content/70">
|
<p class="text-sm text-base-content/70 ml-11">
|
||||||
Matrícula: {grupo.funcionario.matricula}
|
Matrícula: <span class="font-semibold">{grupo.funcionario.matricula}</span>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{#if grupo.funcionario?.descricaoCargo}
|
||||||
|
<p class="text-sm text-base-content/60 ml-11">
|
||||||
|
{grupo.funcionario.descricaoCargo}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3 flex-wrap">
|
||||||
<!-- Banco de Horas -->
|
<!-- Banco de Horas -->
|
||||||
{#key grupo.funcionarioId}
|
{#key grupo.funcionarioId}
|
||||||
{@const bancoHorasQuery = useQuery(
|
{@const bancoHorasQuery = useQuery(
|
||||||
@@ -1523,16 +1942,18 @@
|
|||||||
{@const saldoPositivo = saldoAcumulado >= 0}
|
{@const saldoPositivo = saldoAcumulado >= 0}
|
||||||
|
|
||||||
{#if bancoHoras}
|
{#if bancoHoras}
|
||||||
<div class="mx-4 rounded-lg border-2 p-3 {saldoPositivo ? 'border-success bg-success/10' : 'border-error bg-error/10'}">
|
<div class="rounded-xl border-2 p-4 shadow-md transition-all hover:scale-105 {saldoPositivo ? 'border-success/50 bg-gradient-to-br from-success/10 to-success/5' : 'border-error/50 bg-gradient-to-br from-error/10 to-error/5'}">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="p-2 {saldoPositivo ? 'bg-success/20' : 'bg-error/20'} rounded-lg">
|
||||||
{#if saldoPositivo}
|
{#if saldoPositivo}
|
||||||
<TrendingUp class="h-5 w-5 text-success" />
|
<TrendingUp class="h-5 w-5 text-success" strokeWidth={2.5} />
|
||||||
{:else}
|
{:else}
|
||||||
<TrendingDown class="h-5 w-5 text-error" />
|
<TrendingDown class="h-5 w-5 text-error" strokeWidth={2.5} />
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xs font-semibold opacity-70">Banco de Horas</p>
|
<p class="text-xs font-semibold opacity-70 mb-1">Banco de Horas</p>
|
||||||
<p class="text-lg font-bold">
|
<p class="text-xl font-bold {saldoPositivo ? 'text-success' : 'text-error'}">
|
||||||
{formatarSaldoHoras(saldoAcumulado)}
|
{formatarSaldoHoras(saldoAcumulado)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1542,24 +1963,25 @@
|
|||||||
{/key}
|
{/key}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="btn btn-sm btn-primary"
|
class="btn btn-primary gap-2 shadow-md hover:shadow-lg transition-all"
|
||||||
onclick={() => abrirModalImpressao(grupo.funcionarioId)}
|
onclick={() => abrirModalImpressao(grupo.funcionarioId)}
|
||||||
>
|
>
|
||||||
<Printer class="h-4 w-4" />
|
<Printer class="h-4 w-4" />
|
||||||
Imprimir Ficha
|
Imprimir Ficha
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="overflow-x-auto max-h-[600px] overflow-y-auto border border-base-300 rounded-lg">
|
<div class="overflow-x-auto max-h-[600px] overflow-y-auto border border-base-300 rounded-xl shadow-inner bg-base-100/50">
|
||||||
<table class="table table-zebra">
|
<table class="table table-zebra">
|
||||||
<thead class="sticky top-0 bg-base-200 z-10 shadow-sm">
|
<thead class="sticky top-0 z-10 shadow-md bg-gradient-to-r from-base-300 to-base-200">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="bg-base-200 whitespace-nowrap">Data</th>
|
<th class="whitespace-nowrap font-bold text-base-content border-b border-base-400">Data</th>
|
||||||
<th class="bg-base-200 whitespace-nowrap">Tipo</th>
|
<th class="whitespace-nowrap font-bold text-base-content border-b border-base-400">Tipo</th>
|
||||||
<th class="bg-base-200 whitespace-nowrap">Horário</th>
|
<th class="whitespace-nowrap font-bold text-base-content border-b border-base-400">Horário</th>
|
||||||
<th class="bg-base-200 whitespace-nowrap">Saldo Diário</th>
|
<th class="whitespace-nowrap font-bold text-base-content border-b border-base-400">Saldo Diário</th>
|
||||||
<th class="bg-base-200 whitespace-nowrap">Status</th>
|
<th class="whitespace-nowrap font-bold text-base-content border-b border-base-400">Status</th>
|
||||||
<th class="bg-base-200 whitespace-nowrap">Ações</th>
|
<th class="whitespace-nowrap font-bold text-base-content border-b border-base-400">Ações</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -1585,30 +2007,30 @@
|
|||||||
<td class="whitespace-nowrap" rowspan={totalRegistros}>
|
<td class="whitespace-nowrap" rowspan={totalRegistros}>
|
||||||
{#if grupoData.saldoDiario}
|
{#if grupoData.saldoDiario}
|
||||||
<span
|
<span
|
||||||
class="badge {grupoData.saldoDiario.positivo ? 'badge-success' : 'badge-error'}"
|
class="badge badge-lg font-semibold {grupoData.saldoDiario.positivo ? 'badge-success shadow-sm' : 'badge-error shadow-sm'}"
|
||||||
>
|
>
|
||||||
{formatarSaldoDiario(grupoData.saldoDiario)}
|
{formatarSaldoDiario(grupoData.saldoDiario)}
|
||||||
</span>
|
</span>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="badge badge-ghost">-</span>
|
<span class="badge badge-ghost badge-lg">-</span>
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
{/if}
|
{/if}
|
||||||
<td class="whitespace-nowrap">
|
<td class="whitespace-nowrap">
|
||||||
<span
|
<span
|
||||||
class="badge {registro.dentroDoPrazo ? 'badge-success' : 'badge-error'}"
|
class="badge badge-lg font-semibold {registro.dentroDoPrazo ? 'badge-success shadow-sm' : 'badge-error shadow-sm'}"
|
||||||
>
|
>
|
||||||
{registro.dentroDoPrazo ? 'Dentro do Prazo' : 'Fora do Prazo'}
|
{registro.dentroDoPrazo ? '✓ Dentro do Prazo' : '✗ Fora do Prazo'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="whitespace-nowrap">
|
<td class="whitespace-nowrap">
|
||||||
<button
|
<button
|
||||||
class="btn btn-sm btn-outline btn-primary gap-2"
|
class="btn btn-sm btn-outline btn-primary gap-2 hover:btn-primary hover:shadow-md transition-all"
|
||||||
onclick={() => imprimirDetalhesRegistro(registro._id)}
|
onclick={() => imprimirDetalhesRegistro(registro._id)}
|
||||||
title="Imprimir Detalhes"
|
title="Imprimir Detalhes"
|
||||||
>
|
>
|
||||||
<FileText class="h-4 w-4" />
|
<FileText class="h-4 w-4" />
|
||||||
Imprimir Detalhes
|
Detalhes
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { useQuery, useConvexClient } from 'convex-svelte';
|
import { useQuery, useConvexClient } from 'convex-svelte';
|
||||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||||
import { Clock, Save, CheckCircle2 } from 'lucide-svelte';
|
import { Clock, Save, CheckCircle2, MapPin } from 'lucide-svelte';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
|
||||||
const client = useConvexClient();
|
const client = useConvexClient();
|
||||||
const configQuery = useQuery(api.configuracaoPonto.obterConfiguracao, {});
|
const configQuery = useQuery(api.configuracaoPonto.obterConfiguracao, {});
|
||||||
@@ -93,6 +94,13 @@
|
|||||||
<p class="text-base-content/60 mt-1">Configure os horários de trabalho e tolerâncias</p>
|
<p class="text-base-content/60 mt-1">Configure os horários de trabalho e tolerâncias</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<a
|
||||||
|
href={resolve('/ti/configuracoes-ponto/enderecos')}
|
||||||
|
class="btn btn-secondary gap-2"
|
||||||
|
>
|
||||||
|
<MapPin class="h-5 w-5" />
|
||||||
|
Endereços de Marcação
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mensagens -->
|
<!-- Mensagens -->
|
||||||
|
|||||||
@@ -0,0 +1,804 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { useQuery, useConvexClient } from 'convex-svelte';
|
||||||
|
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||||
|
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||||
|
import { MapPin, Plus, Edit, Power, Search, X, Loader2 } from 'lucide-svelte';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
import { maskCEP, onlyDigits } from '$lib/utils/masks';
|
||||||
|
|
||||||
|
const client = useConvexClient();
|
||||||
|
const enderecosQuery = useQuery(api.enderecosMarcacao.listarEnderecos, { incluirInativos: true });
|
||||||
|
|
||||||
|
const enderecos = $derived(enderecosQuery?.data || []);
|
||||||
|
|
||||||
|
// Estados do formulário
|
||||||
|
let mostrarFormulario = $state(false);
|
||||||
|
let editandoId: Id<'enderecosMarcacao'> | null = $state(null);
|
||||||
|
let processando = $state(false);
|
||||||
|
let termoBusca = $state('');
|
||||||
|
let buscandoCEP = $state(false);
|
||||||
|
let buscandoCoordenadas = $state(false);
|
||||||
|
|
||||||
|
// Campos do formulário
|
||||||
|
let nome = $state('');
|
||||||
|
let descricao = $state('');
|
||||||
|
let latitude = $state<number | ''>('');
|
||||||
|
let longitude = $state<number | ''>('');
|
||||||
|
let endereco = $state('');
|
||||||
|
let bairro = $state('');
|
||||||
|
let cep = $state('');
|
||||||
|
let cidade = $state('');
|
||||||
|
let estado = $state('');
|
||||||
|
let pais = $state('Brasil');
|
||||||
|
let raioMetros = $state(100);
|
||||||
|
let tipo: 'sede' | 'home_office' | 'deslocamento' | 'cliente' = $state('sede');
|
||||||
|
|
||||||
|
// Endereços filtrados
|
||||||
|
const enderecosFiltrados = $derived(
|
||||||
|
enderecos.filter((e) => {
|
||||||
|
if (!termoBusca) return true;
|
||||||
|
const busca = termoBusca.toLowerCase();
|
||||||
|
return (
|
||||||
|
e.nome.toLowerCase().includes(busca) ||
|
||||||
|
e.endereco.toLowerCase().includes(busca) ||
|
||||||
|
e.cidade.toLowerCase().includes(busca) ||
|
||||||
|
e.estado.toLowerCase().includes(busca) ||
|
||||||
|
e.tipo.toLowerCase().includes(busca)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Função para buscar endereço por CEP usando ViaCEP
|
||||||
|
async function buscarEnderecoPorCEP() {
|
||||||
|
const cepLimpo = onlyDigits(cep);
|
||||||
|
|
||||||
|
if (cepLimpo.length !== 8) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
buscandoCEP = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`https://viacep.com.br/ws/${cepLimpo}/json/`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.erro) {
|
||||||
|
toast.error('CEP não encontrado');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preencher campos automaticamente
|
||||||
|
endereco = data.logradouro || '';
|
||||||
|
bairro = data.bairro || '';
|
||||||
|
cidade = data.localidade || '';
|
||||||
|
estado = data.uf || '';
|
||||||
|
|
||||||
|
// Formatar CEP com máscara
|
||||||
|
cep = maskCEP(cepLimpo);
|
||||||
|
|
||||||
|
// Se tiver endereço, tentar buscar coordenadas
|
||||||
|
if (endereco && cidade && estado) {
|
||||||
|
await buscarCoordenadasPorEndereco();
|
||||||
|
} else {
|
||||||
|
toast.success('Endereço encontrado! Preencha o número do endereço e busque as coordenadas.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao buscar CEP:', error);
|
||||||
|
toast.error('Erro ao buscar CEP. Tente novamente.');
|
||||||
|
} finally {
|
||||||
|
buscandoCEP = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Função para buscar coordenadas por endereço usando Google Maps Geocoding API
|
||||||
|
async function buscarCoordenadasPorEndereco() {
|
||||||
|
if (!endereco.trim() || !cidade.trim() || !estado.trim()) {
|
||||||
|
toast.warning('Preencha o endereço, cidade e estado antes de buscar coordenadas');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
buscandoCoordenadas = true;
|
||||||
|
try {
|
||||||
|
// Construir endereço completo para busca
|
||||||
|
let enderecoCompleto = endereco.trim();
|
||||||
|
if (bairro.trim()) {
|
||||||
|
enderecoCompleto += `, ${bairro.trim()}`;
|
||||||
|
}
|
||||||
|
enderecoCompleto += `, ${cidade.trim()}, ${estado.trim()}, Brasil`;
|
||||||
|
|
||||||
|
// Tentar primeiro com Google Maps Geocoding API (se API key estiver configurada)
|
||||||
|
const googleApiKey = import.meta.env.VITE_GOOGLE_MAPS_API_KEY;
|
||||||
|
|
||||||
|
if (googleApiKey) {
|
||||||
|
try {
|
||||||
|
const googleUrl = `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(enderecoCompleto)}&key=${googleApiKey}&language=pt-BR®ion=br`;
|
||||||
|
|
||||||
|
const googleResponse = await fetch(googleUrl);
|
||||||
|
const googleData = await googleResponse.json();
|
||||||
|
|
||||||
|
if (googleData.status === 'OK' && googleData.results && googleData.results.length > 0) {
|
||||||
|
const resultado = googleData.results[0];
|
||||||
|
const location = resultado.geometry.location;
|
||||||
|
|
||||||
|
latitude = parseFloat(location.lat.toFixed(6));
|
||||||
|
longitude = parseFloat(location.lng.toFixed(6));
|
||||||
|
|
||||||
|
toast.success('Coordenadas encontradas via Google Maps!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (googleError) {
|
||||||
|
console.warn('Erro ao buscar no Google Maps, tentando OpenStreetMap...', googleError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback para Nominatim (OpenStreetMap) se Google Maps falhar ou não tiver API key
|
||||||
|
const nominatimUrl = `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(enderecoCompleto)}&limit=1&addressdetails=1`;
|
||||||
|
|
||||||
|
const response = await fetch(nominatimUrl, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'SGSE-App/1.0'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data && data.length > 0) {
|
||||||
|
const resultado = data[0];
|
||||||
|
latitude = parseFloat(parseFloat(resultado.lat).toFixed(6));
|
||||||
|
longitude = parseFloat(parseFloat(resultado.lon).toFixed(6));
|
||||||
|
|
||||||
|
toast.success('Coordenadas encontradas via OpenStreetMap!');
|
||||||
|
} else {
|
||||||
|
toast.warning('Coordenadas não encontradas. Preencha manualmente.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao buscar coordenadas:', error);
|
||||||
|
toast.error('Erro ao buscar coordenadas. Preencha manualmente.');
|
||||||
|
} finally {
|
||||||
|
buscandoCoordenadas = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler para CEP com máscara e busca automática
|
||||||
|
function handleCEPInput(event: Event) {
|
||||||
|
const target = event.target as HTMLInputElement;
|
||||||
|
const cepLimpo = onlyDigits(target.value);
|
||||||
|
|
||||||
|
// Aplicar máscara
|
||||||
|
cep = maskCEP(cepLimpo);
|
||||||
|
|
||||||
|
// Buscar endereço quando CEP estiver completo
|
||||||
|
if (cepLimpo.length === 8) {
|
||||||
|
setTimeout(() => buscarEnderecoPorCEP(), 500); // Delay para evitar múltiplas requisições
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler para buscar coordenadas quando endereço mudar
|
||||||
|
function handleEnderecoChange() {
|
||||||
|
// Buscar coordenadas automaticamente quando todos os campos estiverem preenchidos
|
||||||
|
if (endereco.trim() && cidade.trim() && estado.trim() && !latitude && !longitude) {
|
||||||
|
// Não buscar automaticamente, apenas quando o usuário clicar em buscar
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function limparFormulario() {
|
||||||
|
nome = '';
|
||||||
|
descricao = '';
|
||||||
|
latitude = '';
|
||||||
|
longitude = '';
|
||||||
|
endereco = '';
|
||||||
|
bairro = '';
|
||||||
|
cep = '';
|
||||||
|
cidade = '';
|
||||||
|
estado = '';
|
||||||
|
pais = 'Brasil';
|
||||||
|
raioMetros = 100;
|
||||||
|
tipo = 'sede';
|
||||||
|
editandoId = null;
|
||||||
|
mostrarFormulario = false;
|
||||||
|
buscandoCEP = false;
|
||||||
|
buscandoCoordenadas = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function abrirFormularioEdicao(enderecoParaEditar: typeof enderecos[number]) {
|
||||||
|
editandoId = enderecoParaEditar._id;
|
||||||
|
nome = enderecoParaEditar.nome;
|
||||||
|
descricao = enderecoParaEditar.descricao || '';
|
||||||
|
latitude = enderecoParaEditar.latitude;
|
||||||
|
longitude = enderecoParaEditar.longitude;
|
||||||
|
endereco = enderecoParaEditar.endereco;
|
||||||
|
bairro = (enderecoParaEditar as any).bairro || '';
|
||||||
|
cep = enderecoParaEditar.cep ? maskCEP(enderecoParaEditar.cep) : '';
|
||||||
|
cidade = enderecoParaEditar.cidade;
|
||||||
|
estado = enderecoParaEditar.estado;
|
||||||
|
pais = enderecoParaEditar.pais || 'Brasil';
|
||||||
|
raioMetros = enderecoParaEditar.raioMetros;
|
||||||
|
tipo = enderecoParaEditar.tipo;
|
||||||
|
buscandoCEP = false;
|
||||||
|
buscandoCoordenadas = false;
|
||||||
|
mostrarFormulario = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function salvarEndereco() {
|
||||||
|
if (!nome.trim()) {
|
||||||
|
toast.error('Nome é obrigatório');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (latitude === '' || longitude === '' || typeof latitude !== 'number' || typeof longitude !== 'number') {
|
||||||
|
toast.error('Latitude e longitude são obrigatórias');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!endereco.trim()) {
|
||||||
|
toast.error('Endereço é obrigatório');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cidade.trim()) {
|
||||||
|
toast.error('Cidade é obrigatória');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!estado.trim()) {
|
||||||
|
toast.error('Estado é obrigatório');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raioMetros < 0 || raioMetros > 50000) {
|
||||||
|
toast.error('Raio deve estar entre 0 e 50000 metros');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
processando = true;
|
||||||
|
try {
|
||||||
|
if (editandoId) {
|
||||||
|
await client.mutation(api.enderecosMarcacao.atualizarEndereco, {
|
||||||
|
enderecoId: editandoId,
|
||||||
|
nome: nome.trim(),
|
||||||
|
descricao: descricao.trim() || undefined,
|
||||||
|
latitude: typeof latitude === 'number' ? latitude : Number(latitude),
|
||||||
|
longitude: typeof longitude === 'number' ? longitude : Number(longitude),
|
||||||
|
endereco: endereco.trim(),
|
||||||
|
bairro: bairro.trim() || undefined,
|
||||||
|
cep: cep.trim() ? onlyDigits(cep.trim()) : undefined,
|
||||||
|
cidade: cidade.trim(),
|
||||||
|
estado: estado.trim(),
|
||||||
|
pais: pais.trim() || undefined,
|
||||||
|
raioMetros,
|
||||||
|
tipo,
|
||||||
|
});
|
||||||
|
toast.success('Endereço atualizado com sucesso!');
|
||||||
|
} else {
|
||||||
|
await client.mutation(api.enderecosMarcacao.criarEndereco, {
|
||||||
|
nome: nome.trim(),
|
||||||
|
descricao: descricao.trim() || undefined,
|
||||||
|
latitude: typeof latitude === 'number' ? latitude : Number(latitude),
|
||||||
|
longitude: typeof longitude === 'number' ? longitude : Number(longitude),
|
||||||
|
endereco: endereco.trim(),
|
||||||
|
bairro: bairro.trim() || undefined,
|
||||||
|
cep: cep.trim() ? onlyDigits(cep.trim()) : undefined,
|
||||||
|
cidade: cidade.trim(),
|
||||||
|
estado: estado.trim(),
|
||||||
|
pais: pais.trim() || undefined,
|
||||||
|
raioMetros,
|
||||||
|
tipo,
|
||||||
|
});
|
||||||
|
toast.success('Endereço criado com sucesso!');
|
||||||
|
}
|
||||||
|
|
||||||
|
limparFormulario();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao salvar endereço:', error);
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : 'Erro ao salvar endereço. Tente novamente.'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
processando = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function desativarEndereco(enderecoId: Id<'enderecosMarcacao'>) {
|
||||||
|
if (!confirm('Tem certeza que deseja desativar este endereço?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.mutation(api.enderecosMarcacao.desativarEndereco, { enderecoId });
|
||||||
|
toast.success('Endereço desativado com sucesso!');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao desativar endereço:', error);
|
||||||
|
toast.error('Erro ao desativar endereço. Tente novamente.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ativarEndereco(enderecoId: Id<'enderecosMarcacao'>) {
|
||||||
|
try {
|
||||||
|
await client.mutation(api.enderecosMarcacao.atualizarEndereco, {
|
||||||
|
enderecoId,
|
||||||
|
ativo: true,
|
||||||
|
});
|
||||||
|
toast.success('Endereço ativado com sucesso!');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao ativar endereço:', error);
|
||||||
|
toast.error('Erro ao ativar endereço. Tente novamente.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tiposLabel: Record<'sede' | 'home_office' | 'deslocamento' | 'cliente', string> = {
|
||||||
|
sede: 'Sede Principal',
|
||||||
|
home_office: 'Home Office',
|
||||||
|
deslocamento: 'Deslocamento',
|
||||||
|
cliente: 'Cliente',
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="container mx-auto px-4 py-6 max-w-7xl">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="p-3 bg-primary/10 rounded-xl">
|
||||||
|
<MapPin class="h-8 w-8 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-base-content">Endereços de Marcação</h1>
|
||||||
|
<p class="text-base-content/60 mt-1">
|
||||||
|
Gerenciar locais permitidos para registro de ponto
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="btn btn-primary gap-2"
|
||||||
|
onclick={() => {
|
||||||
|
limparFormulario();
|
||||||
|
mostrarFormulario = true;
|
||||||
|
}}
|
||||||
|
disabled={mostrarFormulario}
|
||||||
|
>
|
||||||
|
<Plus class="h-5 w-5" />
|
||||||
|
Novo Endereço
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Formulário Modal -->
|
||||||
|
{#if mostrarFormulario}
|
||||||
|
<div class="modal modal-open">
|
||||||
|
<div class="modal-box max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<!-- Header do Modal -->
|
||||||
|
<div class="flex items-center justify-between mb-6 pb-4 border-b border-base-300">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="p-2 bg-primary/10 rounded-lg">
|
||||||
|
<MapPin class="h-6 w-6 text-primary" strokeWidth={2.5} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold text-base-content">
|
||||||
|
{editandoId ? 'Editar Endereço' : 'Novo Endereço'}
|
||||||
|
</h2>
|
||||||
|
<p class="text-sm text-base-content/60 mt-1">
|
||||||
|
{editandoId ? 'Atualize as informações do endereço' : 'Preencha os dados do novo endereço de marcação'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-circle btn-ghost hover:btn-error transition-all" onclick={limparFormulario}>
|
||||||
|
<X class="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Seção 1: Informações Básicas -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center gap-2 mb-4">
|
||||||
|
<div class="h-px flex-1 bg-gradient-to-r from-transparent via-base-300 to-transparent"></div>
|
||||||
|
<h3 class="text-lg font-semibold text-base-content flex items-center gap-2">
|
||||||
|
<span class="badge badge-primary badge-sm">1</span>
|
||||||
|
Informações Básicas
|
||||||
|
</h3>
|
||||||
|
<div class="h-px flex-1 bg-gradient-to-r from-transparent via-base-300 to-transparent"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-semibold">Nome *</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={nome}
|
||||||
|
placeholder="Ex: Sede Principal, Home Office João"
|
||||||
|
class="input input-bordered input-primary focus:input-primary focus:ring-2 focus:ring-primary/20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-semibold">Tipo *</span>
|
||||||
|
</label>
|
||||||
|
<select bind:value={tipo} class="select select-bordered select-primary focus:select-primary focus:ring-2 focus:ring-primary/20">
|
||||||
|
<option value="sede">🏢 Sede Principal</option>
|
||||||
|
<option value="home_office">🏠 Home Office</option>
|
||||||
|
<option value="deslocamento">🚗 Deslocamento</option>
|
||||||
|
<option value="cliente">👥 Cliente</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-semibold">Descrição</span>
|
||||||
|
<span class="label-text-alt text-base-content/50">(Opcional)</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
bind:value={descricao}
|
||||||
|
placeholder="Descrição detalhada do endereço ou observações importantes..."
|
||||||
|
class="textarea textarea-bordered textarea-primary focus:textarea-primary focus:ring-2 focus:ring-primary/20"
|
||||||
|
rows="3"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Seção 2: Endereço Físico -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center gap-2 mb-4">
|
||||||
|
<div class="h-px flex-1 bg-gradient-to-r from-transparent via-base-300 to-transparent"></div>
|
||||||
|
<h3 class="text-lg font-semibold text-base-content flex items-center gap-2">
|
||||||
|
<span class="badge badge-primary badge-sm">2</span>
|
||||||
|
Endereço Físico
|
||||||
|
</h3>
|
||||||
|
<div class="h-px flex-1 bg-gradient-to-r from-transparent via-base-300 to-transparent"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-semibold">Endereço *</span>
|
||||||
|
</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={endereco}
|
||||||
|
oninput={handleEnderecoChange}
|
||||||
|
placeholder="Ex: Rua Deputado Cunha Rabelo, 214"
|
||||||
|
class="input input-bordered input-primary focus:input-primary focus:ring-2 focus:ring-primary/20 flex-1"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary gap-2"
|
||||||
|
onclick={buscarCoordenadasPorEndereco}
|
||||||
|
disabled={buscandoCoordenadas || !endereco.trim() || !cidade.trim() || !estado.trim()}
|
||||||
|
title="Buscar coordenadas GPS automaticamente"
|
||||||
|
>
|
||||||
|
{#if buscandoCoordenadas}
|
||||||
|
<Loader2 class="h-4 w-4 animate-spin" />
|
||||||
|
{:else}
|
||||||
|
<MapPin class="h-4 w-4" />
|
||||||
|
{/if}
|
||||||
|
<span class="hidden sm:inline">Buscar GPS</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-semibold">Bairro</span>
|
||||||
|
<span class="label-text-alt text-base-content/50">(Opcional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={bairro}
|
||||||
|
placeholder="Ex: Boa Viagem, Centro, Pina"
|
||||||
|
class="input input-bordered focus:input-primary focus:ring-2 focus:ring-primary/20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-semibold">CEP</span>
|
||||||
|
<span class="label-text-alt text-base-content/50">(Opcional - Busca automática)</span>
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={cep}
|
||||||
|
oninput={handleCEPInput}
|
||||||
|
onblur={() => {
|
||||||
|
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}
|
||||||
|
<div class="absolute right-3 top-1/2 transform -translate-y-1/2">
|
||||||
|
<Loader2 class="h-5 w-5 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text-alt text-info">
|
||||||
|
{#if buscandoCEP}
|
||||||
|
Buscando endereço...
|
||||||
|
{:else if cep}
|
||||||
|
Digite o CEP completo para buscar automaticamente
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-semibold">Cidade *</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={cidade}
|
||||||
|
placeholder="Ex: Recife"
|
||||||
|
class="input input-bordered input-primary focus:input-primary focus:ring-2 focus:ring-primary/20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-semibold">Estado *</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={estado}
|
||||||
|
placeholder="Ex: PE"
|
||||||
|
maxlength="2"
|
||||||
|
class="input input-bordered input-primary focus:input-primary focus:ring-2 focus:ring-primary/20 uppercase"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-semibold">País</span>
|
||||||
|
<span class="label-text-alt text-base-content/50">(Opcional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={pais}
|
||||||
|
placeholder="Brasil"
|
||||||
|
class="input input-bordered focus:input-primary focus:ring-2 focus:ring-primary/20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Seção 3: Localização GPS e Configuração -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center gap-2 mb-4">
|
||||||
|
<div class="h-px flex-1 bg-gradient-to-r from-transparent via-base-300 to-transparent"></div>
|
||||||
|
<h3 class="text-lg font-semibold text-base-content flex items-center gap-2">
|
||||||
|
<span class="badge badge-primary badge-sm">3</span>
|
||||||
|
Localização GPS e Configuração
|
||||||
|
</h3>
|
||||||
|
<div class="h-px flex-1 bg-gradient-to-r from-transparent via-base-300 to-transparent"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-base-200/50 rounded-xl p-4 border border-base-300 mb-4">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<div class="p-2 bg-info/20 rounded-lg mt-0.5">
|
||||||
|
<MapPin class="h-5 w-5 text-info" strokeWidth={2} />
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-sm font-semibold text-base-content mb-1">Coordenadas GPS</p>
|
||||||
|
<p class="text-xs text-base-content/70">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-semibold">Latitude *</span>
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.000001"
|
||||||
|
bind:value={latitude}
|
||||||
|
placeholder="-8.047600"
|
||||||
|
class="input input-bordered input-primary focus:input-primary focus:ring-2 focus:ring-primary/20 w-full"
|
||||||
|
/>
|
||||||
|
{#if buscandoCoordenadas}
|
||||||
|
<div class="absolute right-3 top-1/2 transform -translate-y-1/2">
|
||||||
|
<Loader2 class="h-4 w-4 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text-alt text-base-content/50">Formato: -8.047600</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-semibold">Longitude *</span>
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.000001"
|
||||||
|
bind:value={longitude}
|
||||||
|
placeholder="-34.877000"
|
||||||
|
class="input input-bordered input-primary focus:input-primary focus:ring-2 focus:ring-primary/20 w-full"
|
||||||
|
/>
|
||||||
|
{#if buscandoCoordenadas}
|
||||||
|
<div class="absolute right-3 top-1/2 transform -translate-y-1/2">
|
||||||
|
<Loader2 class="h-4 w-4 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text-alt text-base-content/50">Formato: -34.877000</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-semibold">Raio Permitido *</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
bind:value={raioMetros}
|
||||||
|
min="0"
|
||||||
|
max="50000"
|
||||||
|
placeholder="100"
|
||||||
|
class="input input-bordered input-primary focus:input-primary focus:ring-2 focus:ring-primary/20"
|
||||||
|
/>
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text-alt text-base-content/50">
|
||||||
|
Em metros. Ex: 100, 500, 1000
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer do Modal -->
|
||||||
|
<div class="modal-action mt-6 pt-4 border-t border-base-300">
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost hover:btn-error transition-all"
|
||||||
|
onclick={limparFormulario}
|
||||||
|
disabled={processando}
|
||||||
|
>
|
||||||
|
<X class="h-4 w-4 mr-2" />
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-primary gap-2 shadow-md hover:shadow-lg transition-all"
|
||||||
|
onclick={salvarEndereco}
|
||||||
|
disabled={processando}
|
||||||
|
>
|
||||||
|
{#if processando}
|
||||||
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
|
Salvando...
|
||||||
|
{:else}
|
||||||
|
<MapPin class="h-4 w-4" />
|
||||||
|
{editandoId ? 'Atualizar Endereço' : 'Criar Endereço'}
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Barra de Busca -->
|
||||||
|
<div class="card bg-base-100 shadow-xl mb-6">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="form-control">
|
||||||
|
<div class="relative">
|
||||||
|
<Search class="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-base-content/50" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={termoBusca}
|
||||||
|
placeholder="Buscar por nome, endereço, cidade, estado ou tipo..."
|
||||||
|
class="input input-bordered pl-10 w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Lista de Endereços -->
|
||||||
|
<div class="grid grid-cols-1 gap-4">
|
||||||
|
{#if enderecosFiltrados.length === 0}
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body text-center py-12">
|
||||||
|
<MapPin class="h-16 w-16 text-base-content/20 mx-auto mb-4" />
|
||||||
|
<p class="text-lg text-base-content/60">
|
||||||
|
{termoBusca ? 'Nenhum endereço encontrado' : 'Nenhum endereço cadastrado'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#each enderecosFiltrados as enderecoItem (enderecoItem._id)}
|
||||||
|
<div
|
||||||
|
class="card bg-base-100 shadow-xl {enderecoItem.ativo ? '' : 'opacity-60'}"
|
||||||
|
>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
<h3 class="text-xl font-bold">{enderecoItem.nome}</h3>
|
||||||
|
<span
|
||||||
|
class="badge {enderecoItem.ativo ? 'badge-success' : 'badge-error'}"
|
||||||
|
>
|
||||||
|
{enderecoItem.ativo ? 'Ativo' : 'Inativo'}
|
||||||
|
</span>
|
||||||
|
<span class="badge badge-outline">
|
||||||
|
{tiposLabel[enderecoItem.tipo]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if enderecoItem.descricao}
|
||||||
|
<p class="text-base-content/70 mb-2">{enderecoItem.descricao}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="space-y-1 text-sm">
|
||||||
|
<p class="text-base-content/80">
|
||||||
|
<strong>Endereço:</strong> {enderecoItem.endereco}
|
||||||
|
{#if (enderecoItem as any).bairro}
|
||||||
|
- <strong>Bairro:</strong> {(enderecoItem as any).bairro}
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
<p class="text-base-content/80">
|
||||||
|
<strong>Cidade:</strong> {enderecoItem.cidade}/{enderecoItem.estado}
|
||||||
|
{#if enderecoItem.cep}
|
||||||
|
- CEP: {enderecoItem.cep}
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
<p class="text-base-content/80">
|
||||||
|
<strong>Coordenadas:</strong> {enderecoItem.latitude.toFixed(6)}, {enderecoItem.longitude.toFixed(6)}
|
||||||
|
</p>
|
||||||
|
<p class="text-base-content/80">
|
||||||
|
<strong>Raio Permitido:</strong>
|
||||||
|
{#if enderecoItem.raioMetros >= 1000}
|
||||||
|
{@const raioKm = (enderecoItem.raioMetros / 1000).toFixed(2)}
|
||||||
|
<span> {raioKm} km</span>
|
||||||
|
{:else}
|
||||||
|
<span> {enderecoItem.raioMetros} metros</span>
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-outline btn-primary gap-2"
|
||||||
|
onclick={() => abrirFormularioEdicao(enderecoItem)}
|
||||||
|
>
|
||||||
|
<Edit class="h-4 w-4" />
|
||||||
|
Editar
|
||||||
|
</button>
|
||||||
|
{#if enderecoItem.ativo}
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-outline btn-warning gap-2"
|
||||||
|
onclick={() => desativarEndereco(enderecoItem._id)}
|
||||||
|
>
|
||||||
|
<Power class="h-4 w-4" />
|
||||||
|
Desativar
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-outline btn-success gap-2"
|
||||||
|
onclick={() => ativarEndereco(enderecoItem._id)}
|
||||||
|
>
|
||||||
|
<Power class="h-4 w-4" />
|
||||||
|
Ativar
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
4
packages/backend/convex/_generated/api.d.ts
vendored
4
packages/backend/convex/_generated/api.d.ts
vendored
@@ -28,7 +28,9 @@ import type * as cursos from "../cursos.js";
|
|||||||
import type * as dashboard from "../dashboard.js";
|
import type * as dashboard from "../dashboard.js";
|
||||||
import type * as documentos from "../documentos.js";
|
import type * as documentos from "../documentos.js";
|
||||||
import type * as email from "../email.js";
|
import type * as email from "../email.js";
|
||||||
|
import type * as enderecosMarcacao from "../enderecosMarcacao.js";
|
||||||
import type * as ferias from "../ferias.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 funcionarios from "../funcionarios.js";
|
||||||
import type * as healthCheck from "../healthCheck.js";
|
import type * as healthCheck from "../healthCheck.js";
|
||||||
import type * as http from "../http.js";
|
import type * as http from "../http.js";
|
||||||
@@ -80,7 +82,9 @@ declare const fullApi: ApiFromModules<{
|
|||||||
dashboard: typeof dashboard;
|
dashboard: typeof dashboard;
|
||||||
documentos: typeof documentos;
|
documentos: typeof documentos;
|
||||||
email: typeof email;
|
email: typeof email;
|
||||||
|
enderecosMarcacao: typeof enderecosMarcacao;
|
||||||
ferias: typeof ferias;
|
ferias: typeof ferias;
|
||||||
|
funcionarioEnderecos: typeof funcionarioEnderecos;
|
||||||
funcionarios: typeof funcionarios;
|
funcionarios: typeof funcionarios;
|
||||||
healthCheck: typeof healthCheck;
|
healthCheck: typeof healthCheck;
|
||||||
http: typeof http;
|
http: typeof http;
|
||||||
|
|||||||
@@ -34,17 +34,21 @@ export const obterConfiguracao = query({
|
|||||||
nomeSaidaAlmoco: 'Saída 1',
|
nomeSaidaAlmoco: 'Saída 1',
|
||||||
nomeRetornoAlmoco: 'Entrada 2',
|
nomeRetornoAlmoco: 'Entrada 2',
|
||||||
nomeSaida: 'Saída 2',
|
nomeSaida: 'Saída 2',
|
||||||
|
validarLocalizacao: true,
|
||||||
|
toleranciaDistanciaMetros: 100,
|
||||||
ativo: false,
|
ativo: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Garantir que os nomes padrão estejam definidos
|
// Garantir que os nomes padrão e valores padrão estejam definidos
|
||||||
return {
|
return {
|
||||||
...config,
|
...config,
|
||||||
nomeEntrada: config.nomeEntrada || 'Entrada 1',
|
nomeEntrada: config.nomeEntrada || 'Entrada 1',
|
||||||
nomeSaidaAlmoco: config.nomeSaidaAlmoco || 'Saída 1',
|
nomeSaidaAlmoco: config.nomeSaidaAlmoco || 'Saída 1',
|
||||||
nomeRetornoAlmoco: config.nomeRetornoAlmoco || 'Entrada 2',
|
nomeRetornoAlmoco: config.nomeRetornoAlmoco || 'Entrada 2',
|
||||||
nomeSaida: config.nomeSaida || 'Saída 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()),
|
nomeSaidaAlmoco: v.optional(v.string()),
|
||||||
nomeRetornoAlmoco: v.optional(v.string()),
|
nomeRetornoAlmoco: v.optional(v.string()),
|
||||||
nomeSaida: v.optional(v.string()),
|
nomeSaida: v.optional(v.string()),
|
||||||
|
validarLocalizacao: v.optional(v.boolean()),
|
||||||
|
toleranciaDistanciaMetros: v.optional(v.number()),
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const usuario = await getCurrentUserFunction(ctx);
|
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');
|
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
|
// Desativar configurações antigas
|
||||||
const configsAntigas = await ctx.db
|
const configsAntigas = await ctx.db
|
||||||
.query('configuracaoPonto')
|
.query('configuracaoPonto')
|
||||||
@@ -134,6 +147,8 @@ export const salvarConfiguracao = mutation({
|
|||||||
nomeSaidaAlmoco: args.nomeSaidaAlmoco || 'Saída 1',
|
nomeSaidaAlmoco: args.nomeSaidaAlmoco || 'Saída 1',
|
||||||
nomeRetornoAlmoco: args.nomeRetornoAlmoco || 'Entrada 2',
|
nomeRetornoAlmoco: args.nomeRetornoAlmoco || 'Entrada 2',
|
||||||
nomeSaida: args.nomeSaida || 'Saída 2',
|
nomeSaida: args.nomeSaida || 'Saída 2',
|
||||||
|
validarLocalizacao: args.validarLocalizacao ?? true,
|
||||||
|
toleranciaDistanciaMetros: args.toleranciaDistanciaMetros ?? 100,
|
||||||
ativo: true,
|
ativo: true,
|
||||||
atualizadoPor: usuario._id as Id<'usuarios'>,
|
atualizadoPor: usuario._id as Id<'usuarios'>,
|
||||||
atualizadoEm: Date.now(),
|
atualizadoEm: Date.now(),
|
||||||
|
|||||||
626
packages/backend/convex/enderecosMarcacao.ts
Normal file
626
packages/backend/convex/enderecosMarcacao.ts
Normal file
@@ -0,0 +1,626 @@
|
|||||||
|
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;
|
||||||
|
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<typeof endereco> => 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 };
|
||||||
|
|
||||||
|
|
||||||
246
packages/backend/convex/funcionarioEnderecos.ts
Normal file
246
packages/backend/convex/funcionarioEnderecos.ts
Normal file
@@ -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<typeof item> => 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 };
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -3,6 +3,225 @@ import { mutation, query } from './_generated/server';
|
|||||||
import type { MutationCtx, QueryCtx } from './_generated/server';
|
import type { MutationCtx, QueryCtx } from './_generated/server';
|
||||||
import { getCurrentUserFunction } from './auth';
|
import { getCurrentUserFunction } from './auth';
|
||||||
import type { Id } from './_generated/dataModel';
|
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
|
* Gera URL para upload de imagem do ponto
|
||||||
@@ -96,6 +315,13 @@ export const registrarPonto = mutation({
|
|||||||
latitude: v.optional(v.number()),
|
latitude: v.optional(v.number()),
|
||||||
longitude: v.optional(v.number()),
|
longitude: v.optional(v.number()),
|
||||||
precisao: 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()),
|
endereco: v.optional(v.string()),
|
||||||
cidade: v.optional(v.string()),
|
cidade: v.optional(v.string()),
|
||||||
estado: v.optional(v.string()),
|
estado: v.optional(v.string()),
|
||||||
@@ -150,13 +376,31 @@ export const registrarPonto = mutation({
|
|||||||
.first();
|
.first();
|
||||||
|
|
||||||
// Converter timestamp para data/hora com ajuste de GMT
|
// 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 gmtOffset = configPonto?.gmtOffset ?? 0;
|
||||||
const timestampAjustado = args.timestamp + (gmtOffset * 60 * 60 * 1000);
|
|
||||||
const dataObj = new Date(timestampAjustado);
|
// Calcular horário ajustado manualmente a partir de UTC
|
||||||
const data = dataObj.toISOString().split('T')[0]!; // YYYY-MM-DD
|
const dataUTC = new Date(args.timestamp);
|
||||||
const hora = dataObj.getUTCHours();
|
let hora = dataUTC.getUTCHours() + gmtOffset;
|
||||||
const minuto = dataObj.getUTCMinutes();
|
const minuto = dataUTC.getUTCMinutes();
|
||||||
const segundo = dataObj.getUTCSeconds();
|
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
|
// Verificar se já existe registro no mesmo minuto
|
||||||
const funcionarioId = usuario.funcionarioId; // Já verificado acima, não é undefined
|
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);
|
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
|
// Criar registro
|
||||||
const registroId = await ctx.db.insert('registrosPonto', {
|
const registroId = await ctx.db.insert('registrosPonto', {
|
||||||
funcionarioId: usuario.funcionarioId,
|
funcionarioId: usuario.funcionarioId,
|
||||||
@@ -272,6 +607,22 @@ export const registrarPonto = mutation({
|
|||||||
latitude: args.informacoesDispositivo?.latitude,
|
latitude: args.informacoesDispositivo?.latitude,
|
||||||
longitude: args.informacoesDispositivo?.longitude,
|
longitude: args.informacoesDispositivo?.longitude,
|
||||||
precisao: args.informacoesDispositivo?.precisao,
|
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,
|
endereco: args.informacoesDispositivo?.endereco,
|
||||||
cidade: args.informacoesDispositivo?.cidade,
|
cidade: args.informacoesDispositivo?.cidade,
|
||||||
estado: args.informacoesDispositivo?.estado,
|
estado: args.informacoesDispositivo?.estado,
|
||||||
|
|||||||
@@ -1367,6 +1367,25 @@ export default defineSchema({
|
|||||||
latitude: v.optional(v.number()),
|
latitude: v.optional(v.number()),
|
||||||
longitude: v.optional(v.number()),
|
longitude: v.optional(v.number()),
|
||||||
precisao: 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()),
|
endereco: v.optional(v.string()),
|
||||||
cidade: v.optional(v.string()),
|
cidade: v.optional(v.string()),
|
||||||
estado: v.optional(v.string()),
|
estado: v.optional(v.string()),
|
||||||
@@ -1401,6 +1420,60 @@ export default defineSchema({
|
|||||||
.index("by_dentro_prazo", ["dentroDoPrazo", "data"])
|
.index("by_dentro_prazo", ["dentroDoPrazo", "data"])
|
||||||
.index("by_funcionario_timestamp", ["funcionarioId", "timestamp"]),
|
.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({
|
configuracaoPonto: defineTable({
|
||||||
horarioEntrada: v.string(), // HH:mm
|
horarioEntrada: v.string(), // HH:mm
|
||||||
horarioSaidaAlmoco: v.string(), // HH:mm
|
horarioSaidaAlmoco: v.string(), // HH:mm
|
||||||
@@ -1414,6 +1487,9 @@ export default defineSchema({
|
|||||||
nomeSaida: v.optional(v.string()), // Padrão: "Saída 2"
|
nomeSaida: v.optional(v.string()), // Padrão: "Saída 2"
|
||||||
// Ajuste de fuso horário (GMT offset em horas)
|
// Ajuste de fuso horário (GMT offset em horas)
|
||||||
gmtOffset: v.optional(v.number()), // Padrão: 0 (UTC)
|
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(),
|
ativo: v.boolean(),
|
||||||
atualizadoPor: v.id("usuarios"),
|
atualizadoPor: v.id("usuarios"),
|
||||||
atualizadoEm: v.number(),
|
atualizadoEm: v.number(),
|
||||||
|
|||||||
Reference in New Issue
Block a user