Feat controle ponto #35
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.
|
||||
|
||||
@@ -34,6 +34,12 @@
|
||||
funcionarioId && dataHoje ? { funcionarioId, data: dataHoje } : 'skip'
|
||||
);
|
||||
|
||||
// Query para verificar dispensa ativa
|
||||
const dispensaQuery = useQuery(
|
||||
api.pontos.verificarDispensaAtiva,
|
||||
funcionarioId && dataHoje ? { funcionarioId, data: dataHoje } : 'skip'
|
||||
);
|
||||
|
||||
// Estados
|
||||
let mostrandoWebcam = $state(false);
|
||||
let registrando = $state(false);
|
||||
@@ -150,6 +156,22 @@
|
||||
async function registrarPonto() {
|
||||
if (registrando) return;
|
||||
|
||||
// Verificar se tem funcionário associado
|
||||
if (!temFuncionarioAssociado) {
|
||||
mensagemErroModal = 'Usuário não possui funcionário associado';
|
||||
detalhesErroModal = 'Você não possui um funcionário associado à sua conta. Entre em contato com o administrador do sistema.';
|
||||
mostrarModalErro = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Verificar se está dispensado antes de registrar
|
||||
if (estaDispensado) {
|
||||
mensagemErroModal = 'Registro dispensado pelo gestor';
|
||||
detalhesErroModal = motivoDispensa || 'Você está dispensado de registrar ponto no momento.';
|
||||
mostrarModalErro = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Verificar permissões antes de registrar
|
||||
const permissoes = await verificarPermissoes();
|
||||
if (!permissoes.localizacao || !permissoes.webcam) {
|
||||
@@ -296,6 +318,22 @@
|
||||
async function iniciarRegistroComFoto() {
|
||||
if (registrando || coletandoInfo) return;
|
||||
|
||||
// Verificar se tem funcionário associado
|
||||
if (!temFuncionarioAssociado) {
|
||||
mensagemErroModal = 'Usuário não possui funcionário associado';
|
||||
detalhesErroModal = 'Você não possui um funcionário associado à sua conta. Entre em contato com o administrador do sistema.';
|
||||
mostrarModalErro = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Verificar se está dispensado antes de abrir webcam
|
||||
if (estaDispensado) {
|
||||
mensagemErroModal = 'Registro dispensado pelo gestor';
|
||||
detalhesErroModal = motivoDispensa || 'Você está dispensado de registrar ponto no momento.';
|
||||
mostrarModalErro = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Verificar permissões antes de abrir webcam
|
||||
const permissoes = await verificarPermissoes();
|
||||
if (!permissoes.localizacao || !permissoes.webcam) {
|
||||
@@ -542,8 +580,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
const dispensaAtiva = $derived(dispensaQuery?.data);
|
||||
const estaDispensado = $derived(dispensaAtiva?.dispensado ?? false);
|
||||
const motivoDispensa = $derived(dispensaAtiva?.motivo ?? null);
|
||||
const temFuncionarioAssociado = $derived(funcionarioId !== null);
|
||||
|
||||
const podeRegistrar = $derived.by(() => {
|
||||
return !registrando && !coletandoInfo && config !== undefined;
|
||||
return !registrando && !coletandoInfo && config !== undefined && !estaDispensado && temFuncionarioAssociado;
|
||||
});
|
||||
|
||||
// Referência para o modal
|
||||
@@ -650,55 +693,67 @@
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- 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}
|
||||
<!-- Alerta de Funcionário Não Associado -->
|
||||
{#if !temFuncionarioAssociado}
|
||||
<div class="alert alert-error shadow-lg">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 shrink-0 stroke-current"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<h3 class="font-bold">Funcionário Não Associado</h3>
|
||||
<div class="text-sm">
|
||||
Você não possui um funcionário associado à sua conta.
|
||||
<br />
|
||||
Entre em contato com o administrador do sistema para associar um funcionário à sua conta.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Alerta de Dispensa -->
|
||||
{#if estaDispensado && motivoDispensa && temFuncionarioAssociado}
|
||||
<div class="alert alert-warning shadow-lg">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 shrink-0 stroke-current"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<h3 class="font-bold">Registro de Ponto Dispensado</h3>
|
||||
<div class="text-sm">
|
||||
Você está dispensado de registrar ponto no momento.
|
||||
<br />
|
||||
<strong>Motivo:</strong> {motivoDispensa}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Botões de Registro -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body items-center">
|
||||
<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">
|
||||
{#if sucesso}
|
||||
<div class="alert alert-success w-full">
|
||||
@@ -730,6 +785,11 @@
|
||||
class="btn btn-primary btn-lg"
|
||||
onclick={iniciarRegistroComFoto}
|
||||
disabled={!podeRegistrar}
|
||||
title={!temFuncionarioAssociado
|
||||
? 'Você não possui funcionário associado à sua conta'
|
||||
: estaDispensado
|
||||
? 'Você está dispensado de registrar ponto no momento'
|
||||
: ''}
|
||||
>
|
||||
{#if registrando}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
@@ -738,6 +798,12 @@
|
||||
{:else}
|
||||
Registrando...
|
||||
{/if}
|
||||
{:else if !temFuncionarioAssociado}
|
||||
<XCircle class="h-5 w-5" />
|
||||
Funcionário Não Associado
|
||||
{:else if estaDispensado}
|
||||
<XCircle class="h-5 w-5" />
|
||||
Registro Indisponível
|
||||
{:else if proximoTipo === 'entrada' || proximoTipo === 'retorno_almoco'}
|
||||
<LogIn class="h-5 w-5" />
|
||||
Registrar Entrada
|
||||
@@ -750,6 +816,78 @@
|
||||
</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 -->
|
||||
{#if historicoSaldo && registrosOrdenados.length > 0}
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
@@ -780,57 +918,224 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lista de Registros -->
|
||||
<!-- Timeline de Registros -->
|
||||
<div class="divider"></div>
|
||||
<div class="space-y-2">
|
||||
<h3 class="font-semibold">Registros Realizados</h3>
|
||||
<div class="space-y-3">
|
||||
{#each registrosOrdenados as registro (registro._id)}
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1">
|
||||
<div class="mb-1 flex items-center gap-2">
|
||||
<span class="font-semibold">
|
||||
{config
|
||||
? getTipoRegistroLabel(registro.tipo, {
|
||||
nomeEntrada: config.nomeEntrada,
|
||||
nomeSaidaAlmoco: config.nomeSaidaAlmoco,
|
||||
nomeRetornoAlmoco: config.nomeRetornoAlmoco,
|
||||
nomeSaida: config.nomeSaida,
|
||||
})
|
||||
: getTipoRegistroLabel(registro.tipo)}
|
||||
</span>
|
||||
{#if registro.dentroDoPrazo}
|
||||
<CheckCircle2 class="h-4 w-4 text-success" />
|
||||
{:else}
|
||||
<XCircle class="h-4 w-4 text-error" />
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-lg font-bold">
|
||||
{formatarHoraPonto(registro.hora, registro.minuto)}
|
||||
</p>
|
||||
{#if registro.justificativa}
|
||||
<div class="mt-2 rounded bg-base-300 p-2">
|
||||
<p class="text-xs font-semibold opacity-70">Justificativa:</p>
|
||||
<p class="text-sm">{registro.justificativa}</p>
|
||||
<div class="space-y-4">
|
||||
<h3 class="font-semibold">Timeline do Dia</h3>
|
||||
|
||||
<!-- Timeline Visual com horários padrão e registros reais -->
|
||||
<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">
|
||||
<!-- Tipo de registro e status -->
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
{#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}
|
||||
<span class="text-sm font-semibold text-base-content/80">
|
||||
{config
|
||||
? getTipoRegistroLabel(registro.tipo, {
|
||||
nomeEntrada: config.nomeEntrada,
|
||||
nomeRetornoAlmoco: config.nomeRetornoAlmoco,
|
||||
})
|
||||
: getTipoRegistroLabel(registro.tipo)}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex-shrink-0">
|
||||
<button
|
||||
class="btn btn-sm btn-outline btn-primary gap-2"
|
||||
onclick={() => imprimirComprovante(registro._id)}
|
||||
title="Imprimir Comprovante"
|
||||
>
|
||||
<Printer class="h-4 w-4" />
|
||||
Imprimir Comprovante
|
||||
</button>
|
||||
|
||||
<!-- Horário registrado -->
|
||||
<p class="text-3xl font-bold text-primary mb-1">
|
||||
{formatarHoraPonto(registro.hora, registro.minuto)}
|
||||
</p>
|
||||
|
||||
<!-- Comparação com horário esperado -->
|
||||
{#if config}
|
||||
{@const horarioEsperado = registro.tipo === 'entrada' ? config.horarioEntrada : config.horarioRetornoAlmoco}
|
||||
{@const [horaEsperada, minutoEsperado] = horarioEsperado.split(':').map(Number)}
|
||||
{@const minutosEsperados = horaEsperada * 60 + minutoEsperado}
|
||||
{@const minutosRegistrados = registro.hora * 60 + registro.minuto}
|
||||
{@const diferenca = minutosRegistrados - minutosEsperados}
|
||||
{@const diferencaAbs = Math.abs(diferenca)}
|
||||
{@const diferencaTexto = diferencaAbs >= 60
|
||||
? `${Math.floor(diferencaAbs / 60)}h ${diferencaAbs % 60}min`
|
||||
: `${diferencaAbs}min`}
|
||||
|
||||
<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}
|
||||
</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}
|
||||
|
||||
<!-- 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>
|
||||
{/each}
|
||||
|
||||
<!-- 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>
|
||||
{/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>
|
||||
|
||||
@@ -17,36 +17,45 @@
|
||||
async function atualizarTempo() {
|
||||
try {
|
||||
const config = await client.query(api.configuracaoRelogio.obterConfiguracao, {});
|
||||
const gmtOffset = config.gmtOffset ?? 0;
|
||||
|
||||
let timestampBase: number;
|
||||
|
||||
if (config.usarServidorExterno) {
|
||||
try {
|
||||
const resultado = await client.action(api.configuracaoRelogio.sincronizarTempo, {});
|
||||
if (resultado.sucesso && resultado.timestamp) {
|
||||
tempoAtual = new Date(resultado.timestamp);
|
||||
timestampBase = resultado.timestamp;
|
||||
sincronizado = true;
|
||||
usandoServidorExterno = resultado.usandoServidorExterno || false;
|
||||
offsetSegundos = resultado.offsetSegundos || 0;
|
||||
erro = null;
|
||||
} else {
|
||||
throw new Error('Falha ao sincronizar');
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Erro ao sincronizar:', error);
|
||||
if (config.fallbackParaPC) {
|
||||
tempoAtual = new Date(obterTempoPC());
|
||||
timestampBase = obterTempoPC();
|
||||
sincronizado = false;
|
||||
usandoServidorExterno = false;
|
||||
erro = 'Usando relógio do PC (falha na sincronização)';
|
||||
} else {
|
||||
erro = 'Falha ao sincronizar tempo';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Usar tempo do servidor Convex
|
||||
const tempoServidor = await obterTempoServidor(client);
|
||||
tempoAtual = new Date(tempoServidor);
|
||||
timestampBase = await obterTempoServidor(client);
|
||||
sincronizado = true;
|
||||
usandoServidorExterno = false;
|
||||
erro = null;
|
||||
}
|
||||
|
||||
// Aplicar GMT offset ao timestamp
|
||||
// O timestamp está em UTC, adicionar o offset em horas
|
||||
const timestampAjustado = timestampBase + (gmtOffset * 60 * 60 * 1000);
|
||||
tempoAtual = new Date(timestampAjustado);
|
||||
} catch (error) {
|
||||
console.error('Erro ao obter tempo:', error);
|
||||
tempoAtual = new Date(obterTempoPC());
|
||||
|
||||
@@ -15,6 +15,13 @@ export interface InformacoesDispositivo {
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
precisao?: number;
|
||||
altitude?: number | null;
|
||||
altitudeAccuracy?: number | null;
|
||||
heading?: number | null;
|
||||
speed?: number | null;
|
||||
confiabilidadeGPS?: number; // 0-1
|
||||
suspeitaSpoofing?: boolean;
|
||||
motivoSuspeita?: string;
|
||||
endereco?: string;
|
||||
cidade?: string;
|
||||
estado?: string;
|
||||
@@ -230,12 +237,289 @@ function obterInformacoesMemoria(): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtém localização via GPS com múltiplas tentativas
|
||||
* Calcula distância entre duas coordenadas (fórmula de Haversine)
|
||||
* Retorna distância em metros
|
||||
*/
|
||||
function calcularDistancia(
|
||||
lat1: number,
|
||||
lon1: number,
|
||||
lat2: number,
|
||||
lon2: number
|
||||
): number {
|
||||
const R = 6371000; // Raio da Terra em metros
|
||||
const dLat = ((lat2 - lat1) * Math.PI) / 180;
|
||||
const dLon = ((lon2 - lon1) * Math.PI) / 180;
|
||||
const a =
|
||||
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos((lat1 * Math.PI) / 180) *
|
||||
Math.cos((lat2 * Math.PI) / 180) *
|
||||
Math.sin(dLon / 2) *
|
||||
Math.sin(dLon / 2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return R * c;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtém timezone aproximado por coordenadas
|
||||
*/
|
||||
function obterTimezonePorCoordenadas(latitude: number, longitude: number): string {
|
||||
// Pernambuco está em UTC-3 (America/Recife)
|
||||
if (longitude >= -45 && longitude <= -30 && latitude >= -10 && latitude <= 5) {
|
||||
return 'America/Recife'; // UTC-3
|
||||
}
|
||||
|
||||
// Fallback: usar timezone do sistema
|
||||
try {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
} catch {
|
||||
return 'America/Recife'; // Default
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Captura uma única leitura de localização com todas as propriedades disponíveis
|
||||
*/
|
||||
async function capturarLocalizacaoUnica(
|
||||
enableHighAccuracy: boolean = true,
|
||||
timeout: number = 10000
|
||||
): Promise<{
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
precisao?: number;
|
||||
altitude?: number | null;
|
||||
altitudeAccuracy?: number | null;
|
||||
heading?: number | null;
|
||||
speed?: number | null;
|
||||
timestamp?: number;
|
||||
confiabilidade: number; // 0-1
|
||||
}> {
|
||||
return new Promise((resolve) => {
|
||||
if (typeof navigator === 'undefined' || !navigator.geolocation) {
|
||||
resolve({ confiabilidade: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
resolve({ confiabilidade: 0 });
|
||||
}, timeout + 1000);
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
clearTimeout(timeoutId);
|
||||
const coords = position.coords;
|
||||
const { latitude, longitude, accuracy } = coords;
|
||||
|
||||
// Validar coordenadas básicas
|
||||
if (
|
||||
isNaN(latitude) ||
|
||||
isNaN(longitude) ||
|
||||
latitude === 0 ||
|
||||
longitude === 0 ||
|
||||
latitude < -90 ||
|
||||
latitude > 90 ||
|
||||
longitude < -180 ||
|
||||
longitude > 180
|
||||
) {
|
||||
resolve({ confiabilidade: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
// Calcular score de confiabilidade baseado em propriedades do GPS real
|
||||
const sinaisGPSReal = {
|
||||
temAltitude: coords.altitude !== null && coords.altitude !== 0,
|
||||
temAltitudeAccuracy: coords.altitudeAccuracy !== null && coords.altitudeAccuracy > 0,
|
||||
temHeading: coords.heading !== null && !isNaN(coords.heading),
|
||||
temSpeed: coords.speed !== null && !isNaN(coords.speed),
|
||||
precisaoBoa: accuracy < 20, // GPS real geralmente < 20m
|
||||
precisaoMedia: accuracy >= 20 && accuracy < 100,
|
||||
timestampPreciso: position.timestamp > 0
|
||||
};
|
||||
|
||||
// Calcular confiabilidade: cada sinal adiciona pontos
|
||||
let pontos = 0;
|
||||
const maxPontos = 7;
|
||||
|
||||
if (sinaisGPSReal.temAltitude) pontos += 1;
|
||||
if (sinaisGPSReal.temAltitudeAccuracy) pontos += 1;
|
||||
if (sinaisGPSReal.temHeading) pontos += 0.5;
|
||||
if (sinaisGPSReal.temSpeed) pontos += 0.5;
|
||||
if (sinaisGPSReal.precisaoBoa) pontos += 2;
|
||||
if (sinaisGPSReal.precisaoMedia) pontos += 1;
|
||||
if (sinaisGPSReal.timestampPreciso) pontos += 1;
|
||||
|
||||
const confiabilidade = Math.min(pontos / maxPontos, 1);
|
||||
|
||||
resolve({
|
||||
latitude,
|
||||
longitude,
|
||||
precisao: accuracy,
|
||||
altitude: coords.altitude ?? null,
|
||||
altitudeAccuracy: coords.altitudeAccuracy ?? null,
|
||||
heading: coords.heading ?? null,
|
||||
speed: coords.speed ?? null,
|
||||
timestamp: position.timestamp,
|
||||
confiabilidade
|
||||
});
|
||||
},
|
||||
(error) => {
|
||||
clearTimeout(timeoutId);
|
||||
console.warn('Erro ao obter localização:', error.code, error.message);
|
||||
resolve({ confiabilidade: 0 });
|
||||
},
|
||||
{
|
||||
enableHighAccuracy,
|
||||
timeout,
|
||||
maximumAge: 0 // Sempre obter nova leitura
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtém localização via GPS com múltiplas leituras para detectar spoofing
|
||||
* Apps de spoofing geralmente retornam valores idênticos em todas as leituras
|
||||
*/
|
||||
async function obterLocalizacaoMultipla(): Promise<{
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
precisao?: number;
|
||||
altitude?: number | null;
|
||||
altitudeAccuracy?: number | null;
|
||||
heading?: number | null;
|
||||
speed?: number | null;
|
||||
confiabilidade: number; // 0-1
|
||||
suspeitaSpoofing: boolean;
|
||||
motivoSuspeita?: string;
|
||||
}> {
|
||||
if (typeof navigator === 'undefined' || !navigator.geolocation) {
|
||||
return { confiabilidade: 0, suspeitaSpoofing: true, motivoSuspeita: 'Geolocalização não suportada' };
|
||||
}
|
||||
|
||||
// Capturar 3 leituras com intervalo de 2 segundos entre elas
|
||||
const leituras: Array<{
|
||||
lat: number;
|
||||
lon: number;
|
||||
precisao: number;
|
||||
altitude: number | null;
|
||||
confiabilidade: number;
|
||||
}> = [];
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const leitura = await capturarLocalizacaoUnica(true, 8000);
|
||||
|
||||
if (leitura.latitude && leitura.longitude && leitura.confiabilidade > 0) {
|
||||
leituras.push({
|
||||
lat: leitura.latitude,
|
||||
lon: leitura.longitude,
|
||||
precisao: leitura.precisao || 999,
|
||||
altitude: leitura.altitude ?? null,
|
||||
confiabilidade: leitura.confiabilidade
|
||||
});
|
||||
}
|
||||
|
||||
// Aguardar 2 segundos entre leituras (exceto na última)
|
||||
if (i < 2) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
}
|
||||
}
|
||||
|
||||
if (leituras.length === 0) {
|
||||
return { confiabilidade: 0, suspeitaSpoofing: true, motivoSuspeita: 'Não foi possível obter localização' };
|
||||
}
|
||||
|
||||
// Se tivermos menos de 2 leituras, usar única leitura com baixa confiança
|
||||
if (leituras.length < 2) {
|
||||
const unica = leituras[0];
|
||||
return {
|
||||
latitude: unica.lat,
|
||||
longitude: unica.lon,
|
||||
precisao: unica.precisao,
|
||||
altitude: unica.altitude,
|
||||
altitudeAccuracy: null,
|
||||
heading: null,
|
||||
speed: null,
|
||||
confiabilidade: unica.confiabilidade * 0.5, // Reduzir confiança por ter apenas 1 leitura
|
||||
suspeitaSpoofing: true,
|
||||
motivoSuspeita: 'Apenas uma leitura obtida'
|
||||
};
|
||||
}
|
||||
|
||||
// Verificar se todas as leituras são idênticas (suspeito de spoofing)
|
||||
const primeiraLeitura = leituras[0];
|
||||
const todasIguais = leituras.every(
|
||||
(l) =>
|
||||
Math.abs(l.lat - primeiraLeitura.lat) < 0.00001 && // ~1 metro
|
||||
Math.abs(l.lon - primeiraLeitura.lon) < 0.00001
|
||||
);
|
||||
|
||||
if (todasIguais && leituras.length === 3) {
|
||||
// GPS real varia alguns metros, se todas são idênticas pode ser spoofing
|
||||
return {
|
||||
latitude: primeiraLeitura.lat,
|
||||
longitude: primeiraLeitura.lon,
|
||||
precisao: primeiraLeitura.precisao,
|
||||
altitude: primeiraLeitura.altitude,
|
||||
altitudeAccuracy: null,
|
||||
heading: null,
|
||||
speed: null,
|
||||
confiabilidade: primeiraLeitura.confiabilidade * 0.4, // Reduzir drasticamente confiança
|
||||
suspeitaSpoofing: true,
|
||||
motivoSuspeita: 'Todas as leituras são idênticas (GPS real varia alguns metros)'
|
||||
};
|
||||
}
|
||||
|
||||
// Calcular média das leituras e variância
|
||||
const mediaLat = leituras.reduce((sum, l) => sum + l.lat, 0) / leituras.length;
|
||||
const mediaLon = leituras.reduce((sum, l) => sum + l.lon, 0) / leituras.length;
|
||||
const mediaConfianca = leituras.reduce((sum, l) => sum + l.confiabilidade, 0) / leituras.length;
|
||||
|
||||
// Calcular distância máxima entre leituras
|
||||
let distanciaMaxima = 0;
|
||||
for (let i = 0; i < leituras.length; i++) {
|
||||
for (let j = i + 1; j < leituras.length; j++) {
|
||||
const dist = calcularDistancia(
|
||||
leituras[i].lat,
|
||||
leituras[i].lon,
|
||||
leituras[j].lat,
|
||||
leituras[j].lon
|
||||
);
|
||||
distanciaMaxima = Math.max(distanciaMaxima, dist);
|
||||
}
|
||||
}
|
||||
|
||||
// Se distância máxima for muito grande (> 100m), pode indicar problemas
|
||||
const suspeitoPorDistancia = distanciaMaxima > 100;
|
||||
|
||||
return {
|
||||
latitude: mediaLat,
|
||||
longitude: mediaLon,
|
||||
precisao: primeiraLeitura.precisao,
|
||||
altitude: primeiraLeitura.altitude,
|
||||
altitudeAccuracy: null,
|
||||
heading: null,
|
||||
speed: null,
|
||||
confiabilidade: suspeitoPorDistancia ? mediaConfianca * 0.6 : mediaConfianca,
|
||||
suspeitaSpoofing: suspeitoPorDistancia,
|
||||
motivoSuspeita: suspeitoPorDistancia
|
||||
? `Variação muito grande entre leituras (${Math.round(distanciaMaxima)}m)`
|
||||
: undefined
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtém localização via GPS com múltiplas tentativas e validações anti-spoofing
|
||||
*/
|
||||
async function obterLocalizacao(): Promise<{
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
precisao?: number;
|
||||
altitude?: number | null;
|
||||
altitudeAccuracy?: number | null;
|
||||
heading?: number | null;
|
||||
speed?: number | null;
|
||||
confiabilidadeGPS?: number;
|
||||
suspeitaSpoofing?: boolean;
|
||||
motivoSuspeita?: string;
|
||||
endereco?: string;
|
||||
cidade?: string;
|
||||
estado?: string;
|
||||
@@ -246,127 +530,95 @@ async function obterLocalizacao(): Promise<{
|
||||
return {};
|
||||
}
|
||||
|
||||
// Tentar múltiplas estratégias
|
||||
const estrategias = [
|
||||
// Estratégia 1: Alta precisão (mais lento, mas mais preciso)
|
||||
{
|
||||
enableHighAccuracy: true,
|
||||
timeout: 10000,
|
||||
maximumAge: 0
|
||||
},
|
||||
// Estratégia 2: Precisão média (balanceado)
|
||||
{
|
||||
enableHighAccuracy: false,
|
||||
timeout: 8000,
|
||||
maximumAge: 30000
|
||||
},
|
||||
// Estratégia 3: Rápido (usa cache)
|
||||
{
|
||||
enableHighAccuracy: false,
|
||||
timeout: 5000,
|
||||
maximumAge: 60000
|
||||
}
|
||||
];
|
||||
// Usar múltiplas leituras para detectar spoofing
|
||||
const localizacaoMultipla = await obterLocalizacaoMultipla();
|
||||
|
||||
for (const options of estrategias) {
|
||||
try {
|
||||
const resultado = await new Promise<{
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
precisao?: number;
|
||||
endereco?: string;
|
||||
cidade?: string;
|
||||
estado?: string;
|
||||
pais?: string;
|
||||
}>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
resolve({});
|
||||
}, options.timeout + 1000);
|
||||
if (!localizacaoMultipla.latitude || !localizacaoMultipla.longitude) {
|
||||
console.warn('Não foi possível obter localização');
|
||||
return {
|
||||
confiabilidadeGPS: 0,
|
||||
suspeitaSpoofing: true,
|
||||
motivoSuspeita: 'Não foi possível obter localização'
|
||||
};
|
||||
}
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
async (position) => {
|
||||
clearTimeout(timeout);
|
||||
const { latitude, longitude, accuracy } = position.coords;
|
||||
const { latitude, longitude, precisao, altitude, altitudeAccuracy, heading, speed, confiabilidade, suspeitaSpoofing, motivoSuspeita } = localizacaoMultipla;
|
||||
|
||||
// Validar coordenadas
|
||||
if (isNaN(latitude) || isNaN(longitude) || latitude === 0 || longitude === 0) {
|
||||
resolve({});
|
||||
return;
|
||||
}
|
||||
// Tentar obter endereço via reverse geocoding
|
||||
let endereco = '';
|
||||
let cidade = '';
|
||||
let estado = '';
|
||||
let pais = '';
|
||||
|
||||
// Tentar obter endereço via reverse geocoding
|
||||
let endereco = '';
|
||||
let cidade = '';
|
||||
let estado = '';
|
||||
let pais = '';
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://nominatim.openstreetmap.org/reverse?format=json&lat=${latitude}&lon=${longitude}&zoom=18&addressdetails=1`,
|
||||
{
|
||||
headers: {
|
||||
'User-Agent': 'SGSE-App/1.0'
|
||||
}
|
||||
}
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = (await response.json()) as {
|
||||
address?: {
|
||||
road?: string;
|
||||
house_number?: string;
|
||||
city?: string;
|
||||
town?: string;
|
||||
state?: string;
|
||||
country?: string;
|
||||
};
|
||||
};
|
||||
if (data.address) {
|
||||
const addr = data.address;
|
||||
if (addr.road) {
|
||||
endereco = `${addr.road}${addr.house_number ? `, ${addr.house_number}` : ''}`;
|
||||
}
|
||||
cidade = addr.city || addr.town || '';
|
||||
estado = addr.state || '';
|
||||
pais = addr.country || '';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Erro ao obter endereço:', error);
|
||||
}
|
||||
|
||||
resolve({
|
||||
latitude,
|
||||
longitude,
|
||||
precisao: accuracy,
|
||||
endereco,
|
||||
cidade,
|
||||
estado,
|
||||
pais,
|
||||
});
|
||||
},
|
||||
(error) => {
|
||||
clearTimeout(timeout);
|
||||
console.warn('Erro ao obter localização:', error.code, error.message);
|
||||
resolve({});
|
||||
},
|
||||
options
|
||||
);
|
||||
});
|
||||
|
||||
// Se obteve localização, retornar
|
||||
if (resultado.latitude && resultado.longitude) {
|
||||
console.log('Localização obtida com sucesso:', resultado);
|
||||
return resultado;
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://nominatim.openstreetmap.org/reverse?format=json&lat=${latitude}&lon=${longitude}&zoom=18&addressdetails=1`,
|
||||
{
|
||||
headers: {
|
||||
'User-Agent': 'SGSE-App/1.0'
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Erro na estratégia de geolocalização:', error);
|
||||
continue;
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = (await response.json()) as {
|
||||
address?: {
|
||||
road?: string;
|
||||
house_number?: string;
|
||||
city?: string;
|
||||
town?: string;
|
||||
state?: string;
|
||||
country?: string;
|
||||
};
|
||||
};
|
||||
if (data.address) {
|
||||
const addr = data.address;
|
||||
if (addr.road) {
|
||||
endereco = `${addr.road}${addr.house_number ? `, ${addr.house_number}` : ''}`;
|
||||
}
|
||||
cidade = addr.city || addr.town || '';
|
||||
estado = addr.state || '';
|
||||
pais = addr.country || '';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Erro ao obter endereço:', error);
|
||||
}
|
||||
|
||||
// Validar timezone vs localização
|
||||
if (typeof navigator !== 'undefined') {
|
||||
const timezoneAtual = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const timezoneEsperado = obterTimezonePorCoordenadas(latitude, longitude);
|
||||
|
||||
// Se timezone é muito diferente, pode ser suspeito
|
||||
if (timezoneAtual !== timezoneEsperado && timezoneAtual !== 'America/Recife' && timezoneEsperado !== 'America/Recife') {
|
||||
console.warn(`Timezone inconsistente: esperado ${timezoneEsperado}, atual ${timezoneAtual}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Se todas as estratégias falharam, retornar vazio
|
||||
console.warn('Não foi possível obter localização após todas as tentativas');
|
||||
return {};
|
||||
console.log('Localização obtida com validações:', {
|
||||
latitude,
|
||||
longitude,
|
||||
confiabilidade: confiabilidade.toFixed(2),
|
||||
suspeitaSpoofing,
|
||||
motivoSuspeita
|
||||
});
|
||||
|
||||
return {
|
||||
latitude,
|
||||
longitude,
|
||||
precisao,
|
||||
altitude,
|
||||
altitudeAccuracy,
|
||||
heading,
|
||||
speed,
|
||||
confiabilidadeGPS: confiabilidade,
|
||||
suspeitaSpoofing: suspeitaSpoofing || false,
|
||||
motivoSuspeita,
|
||||
endereco,
|
||||
cidade,
|
||||
estado,
|
||||
pais
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -439,6 +691,13 @@ export async function obterInformacoesDispositivo(): Promise<InformacoesDisposit
|
||||
informacoes.latitude = localizacao.latitude;
|
||||
informacoes.longitude = localizacao.longitude;
|
||||
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.cidade = localizacao.cidade;
|
||||
informacoes.estado = localizacao.estado;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { useQuery, useConvexClient } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import { Clock, Plus, X, Trash2 } from 'lucide-svelte';
|
||||
import { Clock, Plus, X, Trash2, AlertTriangle } from 'lucide-svelte';
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
@@ -11,21 +10,32 @@
|
||||
// Estados
|
||||
let funcionariosSelecionados = $state<Id<'funcionarios'>[]>([]);
|
||||
let modoCriacao = $state(false);
|
||||
let mostrandoModalExcluir = $state(false);
|
||||
let dispensaParaExcluir = $state<Id<'dispensasRegistro'> | null>(null);
|
||||
|
||||
// Formulário
|
||||
let dataInicio = $state(new Date().toISOString().split('T')[0]!);
|
||||
let horaInicio = $state(8);
|
||||
let minutoInicio = $state(0);
|
||||
let horaInicioTime = $state('08:00');
|
||||
let dataFim = $state(new Date().toISOString().split('T')[0]!);
|
||||
let horaFim = $state(18);
|
||||
let minutoFim = $state(0);
|
||||
let horaFimTime = $state('18:00');
|
||||
let motivo = $state('');
|
||||
let isento = $state(false);
|
||||
|
||||
// Computed para converter time string para hora/minuto
|
||||
const horaInicio = $derived.by(() => {
|
||||
const [hora, minuto] = horaInicioTime.split(':').map(Number);
|
||||
return { hora: hora || 8, minuto: minuto || 0 };
|
||||
});
|
||||
|
||||
const horaFim = $derived.by(() => {
|
||||
const [hora, minuto] = horaFimTime.split(':').map(Number);
|
||||
return { hora: hora || 18, minuto: minuto || 0 };
|
||||
});
|
||||
|
||||
// Queries
|
||||
const subordinadosQuery = useQuery(api.times.listarSubordinadosDoGestorAtual, {});
|
||||
const dispensasQuery = useQuery(api.pontos.listarDispensas, {
|
||||
apenasAtivas: false, // Mostrar todas para o gestor ver histórico
|
||||
apenasAtivas: true, // Mostrar apenas dispensas ativas
|
||||
});
|
||||
|
||||
const subordinados = $derived(subordinadosQuery?.data || []);
|
||||
@@ -52,11 +62,9 @@
|
||||
modoCriacao = true;
|
||||
funcionariosSelecionados = [];
|
||||
dataInicio = new Date().toISOString().split('T')[0]!;
|
||||
horaInicio = 8;
|
||||
minutoInicio = 0;
|
||||
horaInicioTime = '08:00';
|
||||
dataFim = new Date().toISOString().split('T')[0]!;
|
||||
horaFim = 18;
|
||||
minutoFim = 0;
|
||||
horaFimTime = '18:00';
|
||||
motivo = '';
|
||||
isento = false;
|
||||
}
|
||||
@@ -99,11 +107,11 @@
|
||||
client.mutation(api.pontos.criarDispensaRegistro, {
|
||||
funcionarioId,
|
||||
dataInicio,
|
||||
horaInicio,
|
||||
minutoInicio,
|
||||
horaInicio: horaInicio.hora,
|
||||
minutoInicio: horaInicio.minuto,
|
||||
dataFim,
|
||||
horaFim,
|
||||
minutoFim,
|
||||
horaFim: horaFim.hora,
|
||||
minutoFim: horaFim.minuto,
|
||||
motivo,
|
||||
isento,
|
||||
})
|
||||
@@ -121,15 +129,26 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function removerDispensa(dispensaId: Id<'dispensasRegistro'>) {
|
||||
if (!confirm('Deseja realmente remover esta dispensa?')) return;
|
||||
function abrirModalExcluir(dispensaId: Id<'dispensasRegistro'>) {
|
||||
dispensaParaExcluir = dispensaId;
|
||||
mostrandoModalExcluir = true;
|
||||
}
|
||||
|
||||
function fecharModalExcluir() {
|
||||
mostrandoModalExcluir = false;
|
||||
dispensaParaExcluir = null;
|
||||
}
|
||||
|
||||
async function confirmarRemoverDispensa() {
|
||||
if (!dispensaParaExcluir) return;
|
||||
|
||||
try {
|
||||
await client.mutation(api.pontos.removerDispensaRegistro, {
|
||||
dispensaId,
|
||||
dispensaId: dispensaParaExcluir,
|
||||
});
|
||||
|
||||
toast.success('Dispensa removida com sucesso');
|
||||
fecharModalExcluir();
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
toast.error(`Erro ao remover dispensa: ${errorMessage}`);
|
||||
@@ -164,19 +183,27 @@
|
||||
<!-- Formulário de Criação -->
|
||||
{#if modoCriacao}
|
||||
<div class="card bg-base-100 shadow-xl mb-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title mb-4">Criar Dispensa de Registro</h2>
|
||||
<div class="card-body space-y-6">
|
||||
<h2 class="card-title border-b pb-3 text-xl">Criar Dispensa de Registro</h2>
|
||||
|
||||
<!-- Seleção de Funcionários -->
|
||||
<div class="form-control mb-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Funcionários</span>
|
||||
{#if funcionariosSelecionados.length > 0}
|
||||
<span class="label-text-alt text-primary">
|
||||
{funcionariosSelecionados.length} selecionado(s)
|
||||
</span>
|
||||
{/if}
|
||||
</label>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-2 max-h-60 overflow-y-auto border border-base-300 rounded-lg p-4">
|
||||
<div class="max-h-60 overflow-y-auto border border-base-300 rounded-lg p-4 space-y-2">
|
||||
{#each funcionarios as funcionario}
|
||||
<label class="label cursor-pointer">
|
||||
<span class="label-text">
|
||||
{funcionario.nome} {funcionario.matricula ? `(${funcionario.matricula})` : ''}
|
||||
<label class="flex items-center justify-between p-3 rounded-lg hover:bg-base-200 transition-colors cursor-pointer">
|
||||
<span class="label-text font-medium">
|
||||
{funcionario.nome}
|
||||
{#if funcionario.matricula}
|
||||
<span class="text-base-content/60 ml-2">({funcionario.matricula})</span>
|
||||
{/if}
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -186,90 +213,74 @@
|
||||
/>
|
||||
</label>
|
||||
{/each}
|
||||
{#if funcionarios.length === 0}
|
||||
<div class="text-center py-4 text-base-content/60">
|
||||
Nenhum funcionário disponível
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Período -->
|
||||
<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" class="input input-bordered" bind:value={dataInicio} />
|
||||
<input type="date" class="input input-bordered w-full" bind:value={dataInicio} />
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Hora Início</span>
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="23"
|
||||
class="input input-bordered flex-1"
|
||||
bind:value={horaInicio}
|
||||
/>
|
||||
<span class="self-center">:</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="59"
|
||||
class="input input-bordered flex-1"
|
||||
bind:value={minutoInicio}
|
||||
/>
|
||||
</div>
|
||||
<input type="time" class="input input-bordered w-full" bind:value={horaInicioTime} />
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Data Fim</span>
|
||||
</label>
|
||||
<input type="date" class="input input-bordered" bind:value={dataFim} />
|
||||
<input type="date" class="input input-bordered w-full" bind:value={dataFim} />
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Hora Fim</span>
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="23"
|
||||
class="input input-bordered flex-1"
|
||||
bind:value={horaFim}
|
||||
/>
|
||||
<span class="self-center">:</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="59"
|
||||
class="input input-bordered flex-1"
|
||||
bind:value={minutoFim}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-control md:col-span-2">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Motivo</span>
|
||||
</label>
|
||||
<textarea class="textarea textarea-bordered" bind:value={motivo} rows="3"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-control md:col-span-2">
|
||||
<label class="label cursor-pointer">
|
||||
<span class="label-text font-medium">Isento de Registro (caso excepcional - sem expiração)</span>
|
||||
<input type="checkbox" class="checkbox checkbox-primary" bind:checked={isento} />
|
||||
</label>
|
||||
<p class="text-sm text-base-content/70 mt-1">
|
||||
Se marcado, o funcionário ficará permanentemente dispensado de registrar ponto
|
||||
</p>
|
||||
<input type="time" class="input input-bordered w-full" bind:value={horaFimTime} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mt-4">
|
||||
<button class="btn btn-primary gap-2" onclick={salvarDispensa}>
|
||||
<!-- Motivo -->
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Motivo</span>
|
||||
</label>
|
||||
<textarea
|
||||
class="textarea textarea-bordered"
|
||||
bind:value={motivo}
|
||||
rows="3"
|
||||
placeholder="Descreva o motivo da dispensa de registro de ponto"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Isento -->
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input type="checkbox" class="checkbox checkbox-primary" bind:checked={isento} />
|
||||
<div>
|
||||
<span class="label-text font-medium">Isento de Registro</span>
|
||||
<p class="text-sm text-base-content/70 mt-1">
|
||||
Caso excepcional - sem expiração. O funcionário ficará permanentemente dispensado de registrar ponto.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Ações -->
|
||||
<div class="flex gap-2 pt-4 border-t">
|
||||
<button class="btn btn-primary gap-2 flex-1" onclick={salvarDispensa}>
|
||||
<Plus class="h-4 w-4" />
|
||||
Criar Dispensa
|
||||
</button>
|
||||
@@ -342,7 +353,7 @@
|
||||
<td>
|
||||
<button
|
||||
class="btn btn-sm btn-error gap-2"
|
||||
onclick={() => removerDispensa(dispensa._id)}
|
||||
onclick={() => abrirModalExcluir(dispensa._id)}
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
Remover
|
||||
@@ -356,5 +367,32 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal de Confirmação de Remoção -->
|
||||
{#if mostrandoModalExcluir && dispensaParaExcluir}
|
||||
<dialog class="modal modal-open">
|
||||
<div class="modal-box">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="p-2 bg-error/10 rounded-lg">
|
||||
<AlertTriangle class="h-6 w-6 text-error" strokeWidth={2} />
|
||||
</div>
|
||||
<h3 class="font-bold text-lg">Confirmar Remoção</h3>
|
||||
</div>
|
||||
<p class="text-base-content mb-6">
|
||||
Deseja realmente remover esta dispensa? Esta ação não pode ser desfeita.
|
||||
</p>
|
||||
<div class="modal-action">
|
||||
<button class="btn btn-ghost" onclick={fecharModalExcluir}>Cancelar</button>
|
||||
<button class="btn btn-error gap-2" onclick={confirmarRemoverDispensa}>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
Remover
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop" onclick={fecharModalExcluir}>
|
||||
<button type="button">fechar</button>
|
||||
</form>
|
||||
</dialog>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -65,9 +65,9 @@
|
||||
$: needsScroll = filtered.length > 8;
|
||||
</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 -->
|
||||
<div class="breadcrumbs mb-4 text-sm">
|
||||
<div class="breadcrumbs mb-4 text-sm flex-shrink-0">
|
||||
<ul>
|
||||
<li><a href={resolve('/recursos-humanos')} class="text-primary hover:underline">Recursos Humanos</a></li>
|
||||
<li>Funcionários</li>
|
||||
@@ -75,7 +75,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 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 items-center gap-4">
|
||||
<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>
|
||||
</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
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
@@ -118,7 +118,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
<h2 class="card-title mb-4 text-lg">
|
||||
<svg
|
||||
@@ -223,82 +223,133 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabela de Funcionários -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body p-0">
|
||||
<div class="overflow-x-auto">
|
||||
<div class="overflow-y-auto" style="max-height: {filtered.length > 8 ? '600px' : 'none'};">
|
||||
<table class="table-zebra table w-full">
|
||||
<thead class="bg-base-200 sticky top-0 z-10">
|
||||
<tr>
|
||||
<th class="font-bold">Nome</th>
|
||||
<th class="font-bold">CPF</th>
|
||||
<th class="font-bold">Matrícula</th>
|
||||
<th class="font-bold">Tipo</th>
|
||||
<th class="font-bold">Cidade</th>
|
||||
<th class="font-bold">UF</th>
|
||||
<th class="text-right font-bold">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each filtered as f}
|
||||
<tr class="hover">
|
||||
<td class="font-medium">{f.nome}</td>
|
||||
<td>{f.cpf}</td>
|
||||
<td>{f.matricula}</td>
|
||||
<td>{f.simboloTipo}</td>
|
||||
<td>{f.cidade}</td>
|
||||
<td>{f.uf}</td>
|
||||
<td class="text-right">
|
||||
<div class="dropdown dropdown-end" class:dropdown-open={openMenuId === f._id}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Abrir menu"
|
||||
class="btn btn-sm"
|
||||
onclick={() => toggleMenu(f._id)}
|
||||
>
|
||||
<!-- Container da Tabela com altura responsiva -->
|
||||
<div class="flex-1 flex flex-col min-h-0">
|
||||
<!-- Tabela de Funcionários -->
|
||||
<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 flex-1 flex flex-col min-h-0">
|
||||
<!-- Container com scroll -->
|
||||
<div class="flex-1 overflow-hidden flex flex-col">
|
||||
<div class="overflow-x-auto flex-1 overflow-y-auto">
|
||||
<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>
|
||||
<th class="whitespace-nowrap font-bold text-base-content border-b border-base-400">Nome</th>
|
||||
<th class="whitespace-nowrap font-bold text-base-content border-b border-base-400">CPF</th>
|
||||
<th class="whitespace-nowrap font-bold text-base-content border-b border-base-400">Matrícula</th>
|
||||
<th class="whitespace-nowrap font-bold text-base-content border-b border-base-400">Tipo</th>
|
||||
<th class="whitespace-nowrap font-bold text-base-content border-b border-base-400">Cidade</th>
|
||||
<th class="whitespace-nowrap font-bold text-base-content border-b border-base-400">UF</th>
|
||||
<th class="text-right whitespace-nowrap font-bold text-base-content border-b border-base-400">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<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-5 w-5"
|
||||
viewBox="0 0 20 20"
|
||||
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
|
||||
class="h-16 w-16 text-base-content/30"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
</button>
|
||||
<ul
|
||||
class="dropdown-content menu bg-base-100 rounded-box border-base-300 z-10 w-52 border p-2 shadow-lg"
|
||||
>
|
||||
<li>
|
||||
<a href={`/recursos-humanos/funcionarios/${f._id}`}>Ver Detalhes</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href={`/recursos-humanos/funcionarios/${f._id}/editar`}>Editar</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href={`/recursos-humanos/funcionarios/${f._id}/documentos`}
|
||||
>Ver Documentos</a
|
||||
<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}
|
||||
<tr class="hover:bg-base-200/50 transition-colors">
|
||||
<td class="whitespace-nowrap font-medium">{f.nome}</td>
|
||||
<td class="whitespace-nowrap">{f.cpf}</td>
|
||||
<td class="whitespace-nowrap">{f.matricula}</td>
|
||||
<td class="whitespace-nowrap">
|
||||
<span class="badge badge-outline badge-sm">
|
||||
{f.simboloTipo === 'cargo_comissionado' ? 'Cargo Comissionado' :
|
||||
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}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Abrir menu"
|
||||
class="btn btn-sm btn-ghost hover:btn-primary transition-all"
|
||||
onclick={() => toggleMenu(f._id)}
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<button onclick={() => openPrintModal(f._id)}>Imprimir Ficha</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
viewBox="0 0 20 20"
|
||||
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>
|
||||
</button>
|
||||
<ul
|
||||
class="dropdown-content menu bg-base-100 rounded-box border-base-300 z-20 w-52 border p-2 shadow-xl"
|
||||
>
|
||||
<li>
|
||||
<a href={`/recursos-humanos/funcionarios/${f._id}`} class="hover:bg-primary/10">
|
||||
Ver Detalhes
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href={`/recursos-humanos/funcionarios/${f._id}/editar`} class="hover:bg-primary/10">
|
||||
Editar
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href={`/recursos-humanos/funcionarios/${f._id}/documentos`} class="hover:bg-primary/10">
|
||||
Ver Documentos
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<button onclick={() => openPrintModal(f._id)} class="hover:bg-primary/10">
|
||||
Imprimir Ficha
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Informação sobre resultados -->
|
||||
<div class="text-base-content/70 mt-4 text-center text-sm">
|
||||
Exibindo {filtered.length} de {list.length} funcionário(s)
|
||||
<!-- Informação sobre resultados -->
|
||||
<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 <span class="font-semibold">{filtered.length}</span> de <span class="font-semibold">{list.length}</span> funcionário(s)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal de Impressão -->
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
APOSENTADO_OPTIONS,
|
||||
} from "$lib/utils/constants";
|
||||
import PrintModal from "$lib/components/PrintModal.svelte";
|
||||
import { MapPin } from "lucide-svelte";
|
||||
|
||||
const client = useConvexClient();
|
||||
|
||||
@@ -203,6 +204,16 @@
|
||||
</svg>
|
||||
Imprimir Ficha
|
||||
</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { useQuery, useConvexClient } from 'convex-svelte';
|
||||
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 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>
|
||||
</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>
|
||||
|
||||
<!-- 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
@@ -29,8 +29,10 @@ import type * as cursos from "../cursos.js";
|
||||
import type * as dashboard from "../dashboard.js";
|
||||
import type * as documentos from "../documentos.js";
|
||||
import type * as email from "../email.js";
|
||||
import type * as enderecosMarcacao from "../enderecosMarcacao.js";
|
||||
import type * as empresas from "../empresas.js";
|
||||
import type * as ferias from "../ferias.js";
|
||||
import type * as funcionarioEnderecos from "../funcionarioEnderecos.js";
|
||||
import type * as funcionarios from "../funcionarios.js";
|
||||
import type * as healthCheck from "../healthCheck.js";
|
||||
import type * as http from "../http.js";
|
||||
@@ -82,8 +84,10 @@ declare const fullApi: ApiFromModules<{
|
||||
dashboard: typeof dashboard;
|
||||
documentos: typeof documentos;
|
||||
email: typeof email;
|
||||
enderecosMarcacao: typeof enderecosMarcacao;
|
||||
empresas: typeof empresas;
|
||||
ferias: typeof ferias;
|
||||
funcionarioEnderecos: typeof funcionarioEnderecos;
|
||||
funcionarios: typeof funcionarios;
|
||||
healthCheck: typeof healthCheck;
|
||||
http: typeof http;
|
||||
|
||||
@@ -34,17 +34,21 @@ export const obterConfiguracao = query({
|
||||
nomeSaidaAlmoco: 'Saída 1',
|
||||
nomeRetornoAlmoco: 'Entrada 2',
|
||||
nomeSaida: 'Saída 2',
|
||||
validarLocalizacao: true,
|
||||
toleranciaDistanciaMetros: 100,
|
||||
ativo: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Garantir que os nomes padrão estejam definidos
|
||||
// Garantir que os nomes padrão e valores padrão estejam definidos
|
||||
return {
|
||||
...config,
|
||||
nomeEntrada: config.nomeEntrada || 'Entrada 1',
|
||||
nomeSaidaAlmoco: config.nomeSaidaAlmoco || 'Saída 1',
|
||||
nomeRetornoAlmoco: config.nomeRetornoAlmoco || 'Entrada 2',
|
||||
nomeSaida: config.nomeSaida || 'Saída 2',
|
||||
validarLocalizacao: config.validarLocalizacao ?? true,
|
||||
toleranciaDistanciaMetros: config.toleranciaDistanciaMetros ?? 100,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -63,6 +67,8 @@ export const salvarConfiguracao = mutation({
|
||||
nomeSaidaAlmoco: v.optional(v.string()),
|
||||
nomeRetornoAlmoco: v.optional(v.string()),
|
||||
nomeSaida: v.optional(v.string()),
|
||||
validarLocalizacao: v.optional(v.boolean()),
|
||||
toleranciaDistanciaMetros: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const usuario = await getCurrentUserFunction(ctx);
|
||||
@@ -113,6 +119,13 @@ export const salvarConfiguracao = mutation({
|
||||
throw new Error('Horário de retorno do almoço deve ser anterior à saída');
|
||||
}
|
||||
|
||||
// Validar tolerância de distância se fornecida
|
||||
if (args.toleranciaDistanciaMetros !== undefined) {
|
||||
if (args.toleranciaDistanciaMetros < 0 || args.toleranciaDistanciaMetros > 50000) {
|
||||
throw new Error('Tolerância de distância deve estar entre 0 e 50000 metros');
|
||||
}
|
||||
}
|
||||
|
||||
// Desativar configurações antigas
|
||||
const configsAntigas = await ctx.db
|
||||
.query('configuracaoPonto')
|
||||
@@ -134,6 +147,8 @@ export const salvarConfiguracao = mutation({
|
||||
nomeSaidaAlmoco: args.nomeSaidaAlmoco || 'Saída 1',
|
||||
nomeRetornoAlmoco: args.nomeRetornoAlmoco || 'Entrada 2',
|
||||
nomeSaida: args.nomeSaida || 'Saída 2',
|
||||
validarLocalizacao: args.validarLocalizacao ?? true,
|
||||
toleranciaDistanciaMetros: args.toleranciaDistanciaMetros ?? 100,
|
||||
ativo: true,
|
||||
atualizadoPor: usuario._id as Id<'usuarios'>,
|
||||
atualizadoEm: Date.now(),
|
||||
|
||||
627
packages/backend/convex/enderecosMarcacao.ts
Normal file
627
packages/backend/convex/enderecosMarcacao.ts
Normal file
@@ -0,0 +1,627 @@
|
||||
import { v } from 'convex/values';
|
||||
import { mutation, query } from './_generated/server';
|
||||
import { getCurrentUserFunction } from './auth';
|
||||
import type { Id } from './_generated/dataModel';
|
||||
import type { MutationCtx } from './_generated/server';
|
||||
|
||||
/**
|
||||
* Calcula distância entre duas coordenadas (fórmula de Haversine)
|
||||
* Retorna distância em metros
|
||||
*/
|
||||
function calcularDistancia(
|
||||
lat1: number,
|
||||
lon1: number,
|
||||
lat2: number,
|
||||
lon2: number
|
||||
): number {
|
||||
const R = 6371000; // Raio da Terra em metros
|
||||
const dLat = ((lat2 - lat1) * Math.PI) / 180;
|
||||
const dLon = ((lon2 - lon1) * Math.PI) / 180;
|
||||
const a =
|
||||
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos((lat1 * Math.PI) / 180) *
|
||||
Math.cos((lat2 * Math.PI) / 180) *
|
||||
Math.sin(dLon / 2) *
|
||||
Math.sin(dLon / 2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return R * c;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista todos os endereços de marcação ativos
|
||||
*/
|
||||
export const listarEnderecos = query({
|
||||
args: {
|
||||
incluirInativos: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const usuario = await getCurrentUserFunction(ctx);
|
||||
if (!usuario) {
|
||||
throw new Error('Usuário não autenticado');
|
||||
}
|
||||
|
||||
let enderecos;
|
||||
if (args.incluirInativos) {
|
||||
enderecos = await ctx.db.query('enderecosMarcacao').collect();
|
||||
} else {
|
||||
enderecos = await ctx.db
|
||||
.query('enderecosMarcacao')
|
||||
.withIndex('by_ativo', (q) => q.eq('ativo', true))
|
||||
.collect();
|
||||
}
|
||||
|
||||
// Ordenar por nome
|
||||
return enderecos.sort((a, b) => a.nome.localeCompare(b.nome));
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Obtém um endereço específico
|
||||
*/
|
||||
export const obterEndereco = query({
|
||||
args: {
|
||||
enderecoId: v.id('enderecosMarcacao'),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const usuario = await getCurrentUserFunction(ctx);
|
||||
if (!usuario) {
|
||||
throw new Error('Usuário não autenticado');
|
||||
}
|
||||
|
||||
const endereco = await ctx.db.get(args.enderecoId);
|
||||
if (!endereco) {
|
||||
throw new Error('Endereço não encontrado');
|
||||
}
|
||||
|
||||
return endereco;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Cria um novo endereço de marcação
|
||||
*/
|
||||
export const criarEndereco = mutation({
|
||||
args: {
|
||||
nome: v.string(),
|
||||
descricao: v.optional(v.string()),
|
||||
latitude: v.number(),
|
||||
longitude: v.number(),
|
||||
endereco: v.string(),
|
||||
bairro: v.optional(v.string()),
|
||||
cep: v.optional(v.string()),
|
||||
cidade: v.string(),
|
||||
estado: v.string(),
|
||||
pais: v.optional(v.string()),
|
||||
raioMetros: v.number(),
|
||||
tipo: v.union(
|
||||
v.literal('sede'),
|
||||
v.literal('home_office'),
|
||||
v.literal('deslocamento'),
|
||||
v.literal('cliente')
|
||||
),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const usuario = await getCurrentUserFunction(ctx);
|
||||
if (!usuario) {
|
||||
throw new Error('Usuário não autenticado');
|
||||
}
|
||||
|
||||
// TODO: Verificar permissões (apenas TI ou admin)
|
||||
|
||||
// Validações
|
||||
if (!args.nome || args.nome.trim().length === 0) {
|
||||
throw new Error('Nome é obrigatório');
|
||||
}
|
||||
|
||||
if (
|
||||
isNaN(args.latitude) ||
|
||||
args.latitude < -90 ||
|
||||
args.latitude > 90 ||
|
||||
isNaN(args.longitude) ||
|
||||
args.longitude < -180 ||
|
||||
args.longitude > 180
|
||||
) {
|
||||
throw new Error('Coordenadas inválidas');
|
||||
}
|
||||
|
||||
if (args.raioMetros < 0 || args.raioMetros > 50000) {
|
||||
throw new Error('Raio deve estar entre 0 e 50000 metros');
|
||||
}
|
||||
|
||||
if (!args.endereco || args.endereco.trim().length === 0) {
|
||||
throw new Error('Endereço é obrigatório');
|
||||
}
|
||||
|
||||
if (!args.cidade || args.cidade.trim().length === 0) {
|
||||
throw new Error('Cidade é obrigatória');
|
||||
}
|
||||
|
||||
if (!args.estado || args.estado.trim().length === 0) {
|
||||
throw new Error('Estado é obrigatório');
|
||||
}
|
||||
|
||||
const enderecoId = await ctx.db.insert('enderecosMarcacao', {
|
||||
nome: args.nome.trim(),
|
||||
descricao: args.descricao?.trim(),
|
||||
latitude: args.latitude,
|
||||
longitude: args.longitude,
|
||||
endereco: args.endereco.trim(),
|
||||
bairro: args.bairro?.trim(),
|
||||
cep: args.cep?.trim(),
|
||||
cidade: args.cidade.trim(),
|
||||
estado: args.estado.trim(),
|
||||
pais: args.pais?.trim() || 'Brasil',
|
||||
raioMetros: args.raioMetros,
|
||||
tipo: args.tipo,
|
||||
ativo: true,
|
||||
criadoPor: usuario._id as Id<'usuarios'>,
|
||||
criadoEm: Date.now(),
|
||||
});
|
||||
|
||||
return { enderecoId };
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Atualiza um endereço de marcação
|
||||
*/
|
||||
export const atualizarEndereco = mutation({
|
||||
args: {
|
||||
enderecoId: v.id('enderecosMarcacao'),
|
||||
nome: v.optional(v.string()),
|
||||
descricao: v.optional(v.string()),
|
||||
latitude: v.optional(v.number()),
|
||||
longitude: v.optional(v.number()),
|
||||
endereco: v.optional(v.string()),
|
||||
bairro: v.optional(v.string()),
|
||||
cep: v.optional(v.string()),
|
||||
cidade: v.optional(v.string()),
|
||||
estado: v.optional(v.string()),
|
||||
pais: v.optional(v.string()),
|
||||
raioMetros: v.optional(v.number()),
|
||||
ativo: v.optional(v.boolean()),
|
||||
tipo: v.optional(
|
||||
v.union(
|
||||
v.literal('sede'),
|
||||
v.literal('home_office'),
|
||||
v.literal('deslocamento'),
|
||||
v.literal('cliente')
|
||||
)
|
||||
),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const usuario = await getCurrentUserFunction(ctx);
|
||||
if (!usuario) {
|
||||
throw new Error('Usuário não autenticado');
|
||||
}
|
||||
|
||||
// TODO: Verificar permissões (apenas TI ou admin)
|
||||
|
||||
const endereco = await ctx.db.get(args.enderecoId);
|
||||
if (!endereco) {
|
||||
throw new Error('Endereço não encontrado');
|
||||
}
|
||||
|
||||
const atualizacoes: {
|
||||
nome?: string;
|
||||
descricao?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
endereco?: string;
|
||||
bairro?: string;
|
||||
cep?: string;
|
||||
cidade?: string;
|
||||
estado?: string;
|
||||
pais?: string;
|
||||
raioMetros?: number;
|
||||
tipo?: 'sede' | 'home_office' | 'deslocamento' | 'cliente';
|
||||
ativo?: boolean;
|
||||
atualizadoPor?: Id<'usuarios'>;
|
||||
atualizadoEm?: number;
|
||||
} = {};
|
||||
|
||||
if (args.nome !== undefined) {
|
||||
if (args.nome.trim().length === 0) {
|
||||
throw new Error('Nome não pode ser vazio');
|
||||
}
|
||||
atualizacoes.nome = args.nome.trim();
|
||||
}
|
||||
|
||||
if (args.descricao !== undefined) {
|
||||
atualizacoes.descricao = args.descricao?.trim() || undefined;
|
||||
}
|
||||
|
||||
if (args.latitude !== undefined || args.longitude !== undefined) {
|
||||
const lat = args.latitude ?? endereco.latitude;
|
||||
const lon = args.longitude ?? endereco.longitude;
|
||||
|
||||
if (
|
||||
isNaN(lat) ||
|
||||
lat < -90 ||
|
||||
lat > 90 ||
|
||||
isNaN(lon) ||
|
||||
lon < -180 ||
|
||||
lon > 180
|
||||
) {
|
||||
throw new Error('Coordenadas inválidas');
|
||||
}
|
||||
|
||||
if (args.latitude !== undefined) atualizacoes.latitude = lat;
|
||||
if (args.longitude !== undefined) atualizacoes.longitude = lon;
|
||||
}
|
||||
|
||||
if (args.endereco !== undefined) {
|
||||
if (args.endereco.trim().length === 0) {
|
||||
throw new Error('Endereço não pode ser vazio');
|
||||
}
|
||||
atualizacoes.endereco = args.endereco.trim();
|
||||
}
|
||||
|
||||
if (args.bairro !== undefined) {
|
||||
atualizacoes.bairro = args.bairro?.trim() || undefined;
|
||||
}
|
||||
|
||||
if (args.cep !== undefined) {
|
||||
atualizacoes.cep = args.cep?.trim() || undefined;
|
||||
}
|
||||
|
||||
if (args.cidade !== undefined) {
|
||||
if (args.cidade.trim().length === 0) {
|
||||
throw new Error('Cidade não pode ser vazia');
|
||||
}
|
||||
atualizacoes.cidade = args.cidade.trim();
|
||||
}
|
||||
|
||||
if (args.estado !== undefined) {
|
||||
if (args.estado.trim().length === 0) {
|
||||
throw new Error('Estado não pode ser vazio');
|
||||
}
|
||||
atualizacoes.estado = args.estado.trim();
|
||||
}
|
||||
|
||||
if (args.pais !== undefined) {
|
||||
atualizacoes.pais = args.pais?.trim() || 'Brasil';
|
||||
}
|
||||
|
||||
if (args.raioMetros !== undefined) {
|
||||
if (args.raioMetros < 0 || args.raioMetros > 50000) {
|
||||
throw new Error('Raio deve estar entre 0 e 50000 metros');
|
||||
}
|
||||
atualizacoes.raioMetros = args.raioMetros;
|
||||
}
|
||||
|
||||
if (args.tipo !== undefined) {
|
||||
atualizacoes.tipo = args.tipo;
|
||||
}
|
||||
|
||||
if (args.ativo !== undefined) {
|
||||
atualizacoes.ativo = args.ativo;
|
||||
}
|
||||
|
||||
atualizacoes.atualizadoPor = usuario._id as Id<'usuarios'>;
|
||||
atualizacoes.atualizadoEm = Date.now();
|
||||
|
||||
await ctx.db.patch(args.enderecoId, atualizacoes);
|
||||
|
||||
return { sucesso: true };
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Desativa um endereço de marcação
|
||||
*/
|
||||
export const desativarEndereco = mutation({
|
||||
args: {
|
||||
enderecoId: v.id('enderecosMarcacao'),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const usuario = await getCurrentUserFunction(ctx);
|
||||
if (!usuario) {
|
||||
throw new Error('Usuário não autenticado');
|
||||
}
|
||||
|
||||
// TODO: Verificar permissões (apenas TI ou admin)
|
||||
|
||||
const endereco = await ctx.db.get(args.enderecoId);
|
||||
if (!endereco) {
|
||||
throw new Error('Endereço não encontrado');
|
||||
}
|
||||
|
||||
await ctx.db.patch(args.enderecoId, {
|
||||
ativo: false,
|
||||
atualizadoPor: usuario._id as Id<'usuarios'>,
|
||||
atualizadoEm: Date.now(),
|
||||
});
|
||||
|
||||
return { sucesso: true };
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Obtém endereços permitidos para um funcionário
|
||||
* (leva em conta associações específicas e endereços tipo "sede")
|
||||
*/
|
||||
export const obterEnderecosFuncionario = query({
|
||||
args: {
|
||||
funcionarioId: v.id('funcionarios'),
|
||||
dataAtual: v.optional(v.string()), // YYYY-MM-DD para validar períodos
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const usuario = await getCurrentUserFunction(ctx);
|
||||
if (!usuario) {
|
||||
throw new Error('Usuário não autenticado');
|
||||
}
|
||||
|
||||
const dataAtual = args.dataAtual || new Date().toISOString().split('T')[0]!;
|
||||
|
||||
// Buscar associações específicas do funcionário
|
||||
const associacoes = await ctx.db
|
||||
.query('funcionarioEnderecosMarcacao')
|
||||
.withIndex('by_funcionario_ativo', (q) =>
|
||||
q.eq('funcionarioId', args.funcionarioId).eq('ativo', true)
|
||||
)
|
||||
.collect();
|
||||
|
||||
const enderecosPermitidos: Array<{
|
||||
enderecoId: Id<'enderecosMarcacao'>;
|
||||
raioMetros: number;
|
||||
periodoValido: boolean;
|
||||
}> = [];
|
||||
|
||||
// Processar associações
|
||||
for (const associacao of associacoes) {
|
||||
// Verificar período de validade
|
||||
let periodoValido = true;
|
||||
if (associacao.dataInicio || associacao.dataFim) {
|
||||
if (associacao.dataInicio && dataAtual < associacao.dataInicio) {
|
||||
periodoValido = false;
|
||||
}
|
||||
if (associacao.dataFim && dataAtual > associacao.dataFim) {
|
||||
periodoValido = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!periodoValido) continue;
|
||||
|
||||
const endereco = await ctx.db.get(associacao.enderecoMarcacaoId);
|
||||
if (!endereco || !endereco.ativo) continue;
|
||||
|
||||
// Usar raio personalizado se disponível, senão usar o raio do endereço
|
||||
const raioMetros =
|
||||
associacao.raioMetrosPersonalizado ?? endereco.raioMetros;
|
||||
|
||||
enderecosPermitidos.push({
|
||||
enderecoId: endereco._id,
|
||||
raioMetros,
|
||||
periodoValido: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Se não houver associações específicas, buscar endereços tipo "sede"
|
||||
if (enderecosPermitidos.length === 0) {
|
||||
const enderecosSede = await ctx.db
|
||||
.query('enderecosMarcacao')
|
||||
.withIndex('by_tipo', (q) => q.eq('tipo', 'sede'))
|
||||
.filter((q) => q.eq(q.field('ativo'), true))
|
||||
.collect();
|
||||
|
||||
for (const endereco of enderecosSede) {
|
||||
enderecosPermitidos.push({
|
||||
enderecoId: endereco._id,
|
||||
raioMetros: endereco.raioMetros,
|
||||
periodoValido: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Buscar dados completos dos endereços
|
||||
const enderecosCompletos = await Promise.all(
|
||||
enderecosPermitidos.map(async (item) => {
|
||||
const endereco = await ctx.db.get(item.enderecoId);
|
||||
if (!endereco) return null;
|
||||
|
||||
return {
|
||||
...endereco,
|
||||
raioMetros: item.raioMetros,
|
||||
periodoValido: item.periodoValido,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return enderecosCompletos.filter(
|
||||
(endereco): endereco is NonNullable<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 { getCurrentUserFunction } from './auth';
|
||||
import type { Id } from './_generated/dataModel';
|
||||
import { validarLocalizacaoGeofencingInternal } from './enderecosMarcacao';
|
||||
|
||||
/**
|
||||
* Calcula distância entre duas coordenadas (fórmula de Haversine)
|
||||
* Retorna distância em metros
|
||||
*/
|
||||
function calcularDistancia(
|
||||
lat1: number,
|
||||
lon1: number,
|
||||
lat2: number,
|
||||
lon2: number
|
||||
): number {
|
||||
const R = 6371000; // Raio da Terra em metros
|
||||
const dLat = ((lat2 - lat1) * Math.PI) / 180;
|
||||
const dLon = ((lon2 - lon1) * Math.PI) / 180;
|
||||
const a =
|
||||
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos((lat1 * Math.PI) / 180) *
|
||||
Math.cos((lat2 * Math.PI) / 180) *
|
||||
Math.sin(dLon / 2) *
|
||||
Math.sin(dLon / 2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return R * c;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtém geolocalização aproximada por IP usando serviço externo
|
||||
*/
|
||||
async function obterGeoPorIP(ipAddress: string): Promise<{
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
cidade?: string;
|
||||
estado?: string;
|
||||
pais?: string;
|
||||
} | null> {
|
||||
try {
|
||||
// Usar ipapi.co (gratuito, sem chave para uso limitado)
|
||||
const response = await fetch(`https://ipapi.co/${ipAddress}/json/`, {
|
||||
headers: {
|
||||
'User-Agent': 'SGSE-App/1.0'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = (await response.json()) as {
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
city?: string;
|
||||
region?: string;
|
||||
country_name?: string;
|
||||
error?: boolean;
|
||||
};
|
||||
|
||||
if (!data.error && data.latitude && data.longitude) {
|
||||
return {
|
||||
latitude: data.latitude,
|
||||
longitude: data.longitude,
|
||||
cidade: data.city,
|
||||
estado: data.region,
|
||||
pais: data.country_name
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Erro ao obter geolocalização por IP:', error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Valida localização contra IP geolocation e histórico
|
||||
* Retorna informações detalhadas para salvar no registro
|
||||
*/
|
||||
async function validarLocalizacao(
|
||||
ctx: MutationCtx,
|
||||
funcionarioId: Id<'funcionarios'>,
|
||||
latitude: number,
|
||||
longitude: number,
|
||||
ipAddress?: string,
|
||||
confiabilidadeGPS?: number
|
||||
): Promise<{
|
||||
valida: boolean;
|
||||
motivo?: string;
|
||||
scoreConfianca: number; // 0-1
|
||||
avisos: string[];
|
||||
distanciaIPvsGPS?: number; // Distância em metros entre IP geolocation e GPS
|
||||
velocidadeUltimoRegistro?: number; // Velocidade calculada em km/h
|
||||
distanciaUltimoRegistro?: number; // Distância em metros do último registro
|
||||
tempoDecorridoHoras?: number; // Tempo em horas desde último registro
|
||||
}> {
|
||||
const avisos: string[] = [];
|
||||
let scoreConfianca = confiabilidadeGPS || 0.5;
|
||||
let valida = true;
|
||||
let distanciaIPvsGPS: number | undefined = undefined;
|
||||
let velocidadeUltimoRegistro: number | undefined = undefined;
|
||||
let distanciaUltimoRegistro: number | undefined = undefined;
|
||||
let tempoDecorridoHoras: number | undefined = undefined;
|
||||
|
||||
// 1. Validar coordenadas básicas
|
||||
if (
|
||||
isNaN(latitude) ||
|
||||
isNaN(longitude) ||
|
||||
latitude < -90 ||
|
||||
latitude > 90 ||
|
||||
longitude < -180 ||
|
||||
longitude > 180
|
||||
) {
|
||||
return {
|
||||
valida: false,
|
||||
motivo: 'Coordenadas inválidas',
|
||||
scoreConfianca: 0,
|
||||
avisos: [],
|
||||
distanciaIPvsGPS,
|
||||
velocidadeUltimoRegistro,
|
||||
distanciaUltimoRegistro,
|
||||
tempoDecorridoHoras
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Comparar com geolocalização do IP
|
||||
if (ipAddress) {
|
||||
const ipGeo = await obterGeoPorIP(ipAddress);
|
||||
if (ipGeo) {
|
||||
distanciaIPvsGPS = calcularDistancia(
|
||||
latitude,
|
||||
longitude,
|
||||
ipGeo.latitude,
|
||||
ipGeo.longitude
|
||||
);
|
||||
|
||||
// Se diferença > 50km, muito suspeito
|
||||
if (distanciaIPvsGPS > 50000) {
|
||||
valida = false;
|
||||
scoreConfianca = Math.min(scoreConfianca, 0.2);
|
||||
avisos.push(
|
||||
`Localização GPS (${latitude.toFixed(6)}, ${longitude.toFixed(6)}) está muito distante da localização do IP (${distanciaIPvsGPS.toFixed(0)}m). Possível falsificação.`
|
||||
);
|
||||
} else if (distanciaIPvsGPS > 10000) {
|
||||
// Se diferença entre 10-50km, suspeito mas aceitável (pode ser VPN/mobile)
|
||||
scoreConfianca *= 0.7;
|
||||
avisos.push(
|
||||
`Localização GPS está a ${distanciaIPvsGPS.toFixed(0)}m da localização do IP. Isso pode ser normal se estiver usando VPN ou dados móveis.`
|
||||
);
|
||||
} else if (distanciaIPvsGPS < 5000) {
|
||||
// Se diferença < 5km, aumenta confiança
|
||||
scoreConfianca = Math.min(scoreConfianca + 0.2, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Validar histórico de localizações do funcionário
|
||||
const ultimosRegistros = await ctx.db
|
||||
.query('registrosPonto')
|
||||
.withIndex('by_funcionario_data', (q) => q.eq('funcionarioId', funcionarioId))
|
||||
.order('desc')
|
||||
.take(5);
|
||||
|
||||
if (ultimosRegistros.length > 0) {
|
||||
// Verificar movimento impossível
|
||||
for (const registro of ultimosRegistros) {
|
||||
if (registro.latitude && registro.longitude && registro.timestamp) {
|
||||
distanciaUltimoRegistro = calcularDistancia(
|
||||
latitude,
|
||||
longitude,
|
||||
registro.latitude,
|
||||
registro.longitude
|
||||
);
|
||||
const tempoDecorrido = Date.now() - registro.timestamp;
|
||||
tempoDecorridoHoras = tempoDecorrido / (1000 * 60 * 60);
|
||||
|
||||
// Calcular velocidade (km/h) se tempo decorrido > 0
|
||||
if (tempoDecorridoHoras > 0 && tempoDecorridoHoras < 24) {
|
||||
velocidadeUltimoRegistro = (distanciaUltimoRegistro / 1000) / tempoDecorridoHoras; // km/h
|
||||
|
||||
// Se velocidade > 1000 km/h, impossível (mais rápido que avião)
|
||||
if (velocidadeUltimoRegistro > 1000) {
|
||||
valida = false;
|
||||
scoreConfianca = 0;
|
||||
avisos.push(
|
||||
`Movimento impossível detectado: ${velocidadeUltimoRegistro.toFixed(0)} km/h. Localização anterior há ${tempoDecorridoHoras.toFixed(1)}h está a ${(distanciaUltimoRegistro / 1000).toFixed(1)}km.`
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
// Se velocidade > 200 km/h, suspeito (mas possível em avião)
|
||||
if (velocidadeUltimoRegistro > 200 && velocidadeUltimoRegistro <= 1000) {
|
||||
scoreConfianca *= 0.6;
|
||||
avisos.push(
|
||||
`Movimento muito rápido: ${velocidadeUltimoRegistro.toFixed(0)} km/h. Pode ser viagem, mas verifique se é legítimo.`
|
||||
);
|
||||
}
|
||||
}
|
||||
break; // Usar apenas o último registro
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Validar confiabilidade GPS do frontend
|
||||
if (confiabilidadeGPS !== undefined) {
|
||||
if (confiabilidadeGPS < 0.3) {
|
||||
scoreConfianca *= 0.5;
|
||||
avisos.push(
|
||||
`Confiabilidade GPS baixa (${(confiabilidadeGPS * 100).toFixed(0)}%). Localização pode não ser precisa.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valida,
|
||||
motivo: avisos.length > 0 ? avisos[0] : undefined,
|
||||
scoreConfianca: Math.max(0, Math.min(1, scoreConfianca)),
|
||||
avisos,
|
||||
distanciaIPvsGPS,
|
||||
velocidadeUltimoRegistro,
|
||||
distanciaUltimoRegistro,
|
||||
tempoDecorridoHoras
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gera URL para upload de imagem do ponto
|
||||
@@ -96,6 +315,13 @@ export const registrarPonto = mutation({
|
||||
latitude: v.optional(v.number()),
|
||||
longitude: v.optional(v.number()),
|
||||
precisao: v.optional(v.number()),
|
||||
altitude: v.optional(v.union(v.number(), v.null())),
|
||||
altitudeAccuracy: v.optional(v.union(v.number(), v.null())),
|
||||
heading: v.optional(v.union(v.number(), v.null())),
|
||||
speed: v.optional(v.union(v.number(), v.null())),
|
||||
confiabilidadeGPS: v.optional(v.number()),
|
||||
suspeitaSpoofing: v.optional(v.boolean()),
|
||||
motivoSuspeita: v.optional(v.string()),
|
||||
endereco: v.optional(v.string()),
|
||||
cidade: v.optional(v.string()),
|
||||
estado: v.optional(v.string()),
|
||||
@@ -150,13 +376,31 @@ export const registrarPonto = mutation({
|
||||
.first();
|
||||
|
||||
// Converter timestamp para data/hora com ajuste de GMT
|
||||
// O timestamp está em UTC, precisamos aplicar o GMT offset
|
||||
const gmtOffset = configPonto?.gmtOffset ?? 0;
|
||||
const timestampAjustado = args.timestamp + (gmtOffset * 60 * 60 * 1000);
|
||||
const dataObj = new Date(timestampAjustado);
|
||||
const data = dataObj.toISOString().split('T')[0]!; // YYYY-MM-DD
|
||||
const hora = dataObj.getUTCHours();
|
||||
const minuto = dataObj.getUTCMinutes();
|
||||
const segundo = dataObj.getUTCSeconds();
|
||||
|
||||
// Calcular horário ajustado manualmente a partir de UTC
|
||||
const dataUTC = new Date(args.timestamp);
|
||||
let hora = dataUTC.getUTCHours() + gmtOffset;
|
||||
const minuto = dataUTC.getUTCMinutes();
|
||||
const segundo = dataUTC.getUTCSeconds();
|
||||
|
||||
// Ajustar hora se ultrapassar os limites do dia
|
||||
let diasOffset = 0;
|
||||
if (hora >= 24) {
|
||||
hora = hora - 24;
|
||||
diasOffset = 1;
|
||||
} else if (hora < 0) {
|
||||
hora = hora + 24;
|
||||
diasOffset = -1;
|
||||
}
|
||||
|
||||
// Calcular data ajustada
|
||||
const dataAjustada = new Date(args.timestamp);
|
||||
if (diasOffset !== 0) {
|
||||
dataAjustada.setUTCDate(dataAjustada.getUTCDate() + diasOffset);
|
||||
}
|
||||
const data = dataAjustada.toISOString().split('T')[0]!; // YYYY-MM-DD
|
||||
|
||||
// Verificar se já existe registro no mesmo minuto
|
||||
const funcionarioId = usuario.funcionarioId; // Já verificado acima, não é undefined
|
||||
@@ -244,6 +488,97 @@ export const registrarPonto = mutation({
|
||||
|
||||
const dentroDoPrazo = calcularStatusPonto(hora, minuto, horarioConfigurado, config.toleranciaMinutos);
|
||||
|
||||
// Validar localização se fornecida e salvar informações detalhadas
|
||||
let validacaoLocalizacao: {
|
||||
valida: boolean;
|
||||
motivo?: string;
|
||||
scoreConfianca: number;
|
||||
avisos: string[];
|
||||
distanciaIPvsGPS?: number;
|
||||
velocidadeUltimoRegistro?: number;
|
||||
distanciaUltimoRegistro?: number;
|
||||
tempoDecorridoHoras?: number;
|
||||
} | null = null;
|
||||
|
||||
if (
|
||||
args.informacoesDispositivo?.latitude &&
|
||||
args.informacoesDispositivo?.longitude
|
||||
) {
|
||||
validacaoLocalizacao = await validarLocalizacao(
|
||||
ctx,
|
||||
usuario.funcionarioId,
|
||||
args.informacoesDispositivo.latitude,
|
||||
args.informacoesDispositivo.longitude,
|
||||
args.informacoesDispositivo.ipPublico || args.informacoesDispositivo.ipAddress,
|
||||
args.informacoesDispositivo.confiabilidadeGPS
|
||||
);
|
||||
|
||||
// Sempre registrar, mesmo com baixa confiabilidade
|
||||
// Mas salvar todas as informações detalhadas para análise posterior
|
||||
const suspeitaFrontend = args.informacoesDispositivo.suspeitaSpoofing;
|
||||
const suspeitaBackend = !validacaoLocalizacao.valida;
|
||||
const baixaConfianca = validacaoLocalizacao.scoreConfianca < 0.5;
|
||||
|
||||
if (suspeitaFrontend || suspeitaBackend || baixaConfianca) {
|
||||
console.warn('⚠️ LOCALIZAÇÃO COM BAIXA CONFIABILIDADE DETECTADA (registrando normalmente):', {
|
||||
funcionarioId: usuario.funcionarioId,
|
||||
latitude: args.informacoesDispositivo.latitude,
|
||||
longitude: args.informacoesDispositivo.longitude,
|
||||
confiabilidadeGPSFrontend: args.informacoesDispositivo.confiabilidadeGPS,
|
||||
scoreConfiancaBackend: validacaoLocalizacao.scoreConfianca,
|
||||
suspeitaFrontend: suspeitaFrontend ? args.informacoesDispositivo.motivoSuspeita : null,
|
||||
suspeitaBackend: suspeitaBackend ? validacaoLocalizacao.motivo : null,
|
||||
avisos: validacaoLocalizacao.avisos
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Validar geofencing (localização permitida) se habilitado
|
||||
let validacaoGeofencing: {
|
||||
dentroRaio: boolean;
|
||||
enderecoMaisProximo?: Id<'enderecosMarcacao'>;
|
||||
distanciaMetros?: number;
|
||||
raioUsado?: number;
|
||||
enderecoEncontrado?: string;
|
||||
avisos: string[];
|
||||
} | null = null;
|
||||
|
||||
if (
|
||||
configPonto?.validarLocalizacao !== false &&
|
||||
args.informacoesDispositivo?.latitude &&
|
||||
args.informacoesDispositivo?.longitude
|
||||
) {
|
||||
const geofencing = await validarLocalizacaoGeofencingInternal(
|
||||
ctx,
|
||||
usuario.funcionarioId,
|
||||
args.informacoesDispositivo.latitude,
|
||||
args.informacoesDispositivo.longitude,
|
||||
configPonto?.toleranciaDistanciaMetros ?? 100
|
||||
);
|
||||
|
||||
validacaoGeofencing = geofencing;
|
||||
|
||||
// Adicionar avisos de geofencing aos avisos de validação
|
||||
if (geofencing.avisos.length > 0) {
|
||||
if (!validacaoLocalizacao) {
|
||||
validacaoLocalizacao = {
|
||||
valida: true,
|
||||
scoreConfianca: 1,
|
||||
avisos: [],
|
||||
};
|
||||
}
|
||||
validacaoLocalizacao.avisos.push(...geofencing.avisos);
|
||||
|
||||
// Reduzir score de confiança se estiver fora do raio
|
||||
if (!geofencing.dentroRaio) {
|
||||
validacaoLocalizacao.scoreConfianca = Math.min(
|
||||
validacaoLocalizacao.scoreConfianca,
|
||||
0.7
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Criar registro
|
||||
const registroId = await ctx.db.insert('registrosPonto', {
|
||||
funcionarioId: usuario.funcionarioId,
|
||||
@@ -272,6 +607,22 @@ export const registrarPonto = mutation({
|
||||
latitude: args.informacoesDispositivo?.latitude,
|
||||
longitude: args.informacoesDispositivo?.longitude,
|
||||
precisao: args.informacoesDispositivo?.precisao,
|
||||
altitude: args.informacoesDispositivo?.altitude,
|
||||
altitudeAccuracy: args.informacoesDispositivo?.altitudeAccuracy,
|
||||
heading: args.informacoesDispositivo?.heading,
|
||||
speed: args.informacoesDispositivo?.speed,
|
||||
confiabilidadeGPS: args.informacoesDispositivo?.confiabilidadeGPS,
|
||||
scoreConfiancaBackend: validacaoLocalizacao?.scoreConfianca,
|
||||
suspeitaSpoofing: args.informacoesDispositivo?.suspeitaSpoofing || (validacaoLocalizacao ? validacaoLocalizacao.scoreConfianca < 0.5 || !validacaoLocalizacao.valida : undefined),
|
||||
motivoSuspeita: args.informacoesDispositivo?.motivoSuspeita || validacaoLocalizacao?.motivo || (validacaoLocalizacao && validacaoLocalizacao.avisos.length > 0 ? validacaoLocalizacao.avisos.join('; ') : undefined),
|
||||
// Informações detalhadas de validação (sempre salvar quando houver validação)
|
||||
avisosValidacao: validacaoLocalizacao && validacaoLocalizacao.avisos.length > 0 ? validacaoLocalizacao.avisos : undefined,
|
||||
// Informações de Geofencing
|
||||
enderecoMarcacaoEsperado: validacaoGeofencing?.enderecoMaisProximo,
|
||||
distanciaEnderecoEsperado: validacaoGeofencing?.distanciaMetros,
|
||||
dentroRaioPermitido: validacaoGeofencing?.dentroRaio,
|
||||
enderecoMarcacaoUsado: validacaoGeofencing?.enderecoMaisProximo,
|
||||
raioToleranciaUsado: validacaoGeofencing?.raioUsado,
|
||||
endereco: args.informacoesDispositivo?.endereco,
|
||||
cidade: args.informacoesDispositivo?.cidade,
|
||||
estado: args.informacoesDispositivo?.estado,
|
||||
@@ -384,15 +735,32 @@ export const listarRegistrosPeriodo = query({
|
||||
const dataFim = new Date(args.dataFim);
|
||||
dataFim.setHours(23, 59, 59, 999);
|
||||
|
||||
const registros = await ctx.db
|
||||
.query('registrosPonto')
|
||||
.withIndex('by_data', (q) => q.gte('data', args.dataInicio).lte('data', args.dataFim))
|
||||
.collect();
|
||||
|
||||
// Filtrar por funcionário se especificado
|
||||
let registrosFiltrados = registros;
|
||||
let registrosFiltrados;
|
||||
|
||||
// Se funcionário foi especificado, usar índice por funcionário e data (mais eficiente)
|
||||
if (args.funcionarioId) {
|
||||
registrosFiltrados = registros.filter((r) => r.funcionarioId === args.funcionarioId);
|
||||
// Garantir que funcionarioId não é undefined para TypeScript
|
||||
const funcionarioId = args.funcionarioId;
|
||||
|
||||
// Buscar todos os registros do funcionário
|
||||
const todosRegistrosFuncionario = await ctx.db
|
||||
.query('registrosPonto')
|
||||
.withIndex('by_funcionario_data', (q) => q.eq('funcionarioId', funcionarioId))
|
||||
.collect();
|
||||
|
||||
// Filtrar por período de data
|
||||
registrosFiltrados = todosRegistrosFuncionario.filter((r) => {
|
||||
const dataRegistro = new Date(r.data);
|
||||
return dataRegistro >= new Date(args.dataInicio) && dataRegistro <= dataFim;
|
||||
});
|
||||
} else {
|
||||
// Se não há funcionário especificado, buscar todos e filtrar (menos eficiente, mas necessário)
|
||||
const registros = await ctx.db
|
||||
.query('registrosPonto')
|
||||
.withIndex('by_data', (q) => q.gte('data', args.dataInicio).lte('data', args.dataFim))
|
||||
.collect();
|
||||
|
||||
registrosFiltrados = registros;
|
||||
}
|
||||
|
||||
// Buscar informações dos funcionários
|
||||
@@ -1052,6 +1420,48 @@ export const listarHomologacoes = query({
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Exclui uma homologação (apenas para gestores)
|
||||
*/
|
||||
export const excluirHomologacao = mutation({
|
||||
args: {
|
||||
homologacaoId: v.id('homologacoesPonto'),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const usuario = await getCurrentUserFunction(ctx);
|
||||
if (!usuario) {
|
||||
throw new Error('Usuário não autenticado');
|
||||
}
|
||||
|
||||
const homologacao = await ctx.db.get(args.homologacaoId);
|
||||
if (!homologacao) {
|
||||
throw new Error('Homologação não encontrada');
|
||||
}
|
||||
|
||||
// Verificar se é gestor do funcionário
|
||||
const isGestor = await verificarGestorDoFuncionario(ctx, usuario._id, homologacao.funcionarioId);
|
||||
if (!isGestor && homologacao.gestorId !== usuario._id) {
|
||||
throw new Error('Você não tem permissão para excluir esta homologação');
|
||||
}
|
||||
|
||||
// Se a homologação estiver vinculada a um registro, remover a referência
|
||||
if (homologacao.registroId) {
|
||||
const registro = await ctx.db.get(homologacao.registroId);
|
||||
if (registro && registro.homologacaoId === args.homologacaoId) {
|
||||
await ctx.db.patch(homologacao.registroId, {
|
||||
homologacaoId: undefined,
|
||||
editadoPorGestor: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Excluir homologação
|
||||
await ctx.db.delete(args.homologacaoId);
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Obtém opções de motivos de atestados/declarações
|
||||
*/
|
||||
|
||||
@@ -1416,6 +1416,25 @@ export default defineSchema({
|
||||
latitude: v.optional(v.number()),
|
||||
longitude: v.optional(v.number()),
|
||||
precisao: v.optional(v.number()),
|
||||
altitude: v.optional(v.union(v.number(), v.null())),
|
||||
altitudeAccuracy: v.optional(v.union(v.number(), v.null())),
|
||||
heading: v.optional(v.union(v.number(), v.null())),
|
||||
speed: v.optional(v.union(v.number(), v.null())),
|
||||
confiabilidadeGPS: v.optional(v.number()), // 0-1 (frontend)
|
||||
scoreConfiancaBackend: v.optional(v.number()), // 0-1 (backend)
|
||||
suspeitaSpoofing: v.optional(v.boolean()),
|
||||
motivoSuspeita: v.optional(v.string()),
|
||||
avisosValidacao: v.optional(v.array(v.string())), // Array de avisos detalhados da validação
|
||||
distanciaIPvsGPS: v.optional(v.number()), // Distância em metros entre IP geolocation e GPS
|
||||
velocidadeUltimoRegistro: v.optional(v.number()), // Velocidade em km/h do último registro
|
||||
distanciaUltimoRegistro: v.optional(v.number()), // Distância em metros do último registro
|
||||
tempoDecorridoHoras: v.optional(v.number()), // Tempo em horas desde o último registro
|
||||
// Informações de Geofencing
|
||||
enderecoMarcacaoEsperado: v.optional(v.id("enderecosMarcacao")), // Endereço mais próximo esperado
|
||||
distanciaEnderecoEsperado: v.optional(v.number()), // Distância em metros do endereço esperado
|
||||
dentroRaioPermitido: v.optional(v.boolean()), // Se está dentro do raio permitido
|
||||
enderecoMarcacaoUsado: v.optional(v.id("enderecosMarcacao")), // Qual endereço foi usado na validação
|
||||
raioToleranciaUsado: v.optional(v.number()), // Raio usado na validação em metros
|
||||
endereco: v.optional(v.string()),
|
||||
cidade: v.optional(v.string()),
|
||||
estado: v.optional(v.string()),
|
||||
@@ -1450,6 +1469,60 @@ export default defineSchema({
|
||||
.index("by_dentro_prazo", ["dentroDoPrazo", "data"])
|
||||
.index("by_funcionario_timestamp", ["funcionarioId", "timestamp"]),
|
||||
|
||||
// Endereços de Marcação - Locais permitidos para registro de ponto
|
||||
enderecosMarcacao: defineTable({
|
||||
nome: v.string(), // Ex: "Sede Principal", "Home Office João Silva", "Cliente ABC"
|
||||
descricao: v.optional(v.string()), // Descrição opcional
|
||||
// Coordenadas (obrigatórias)
|
||||
latitude: v.number(),
|
||||
longitude: v.number(),
|
||||
// Endereço físico (para exibição)
|
||||
endereco: v.string(), // Ex: "Rua Exemplo, 123"
|
||||
bairro: v.optional(v.string()), // Bairro do endereço
|
||||
cep: v.optional(v.string()),
|
||||
cidade: v.string(),
|
||||
estado: v.string(),
|
||||
pais: v.optional(v.string()), // Padrão: "Brasil"
|
||||
// Configurações
|
||||
raioMetros: v.number(), // Raio de tolerância em metros (ex: 100m, 500m, 1000m)
|
||||
ativo: v.boolean(),
|
||||
// Tipos de uso
|
||||
tipo: v.union(
|
||||
v.literal("sede"), // Sede principal (para todos)
|
||||
v.literal("home_office"), // Home office específico
|
||||
v.literal("deslocamento"), // Deslocamento temporário
|
||||
v.literal("cliente") // Local de cliente
|
||||
),
|
||||
// Metadados
|
||||
criadoPor: v.id("usuarios"),
|
||||
criadoEm: v.number(),
|
||||
atualizadoPor: v.optional(v.id("usuarios")),
|
||||
atualizadoEm: v.optional(v.number()),
|
||||
})
|
||||
.index("by_ativo", ["ativo"])
|
||||
.index("by_tipo", ["tipo"])
|
||||
.index("by_cidade", ["cidade"]),
|
||||
|
||||
// Associação Funcionário ↔ Endereço de Marcação
|
||||
funcionarioEnderecosMarcacao: defineTable({
|
||||
funcionarioId: v.id("funcionarios"),
|
||||
enderecoMarcacaoId: v.id("enderecosMarcacao"),
|
||||
// Configurações específicas do funcionário
|
||||
raioMetrosPersonalizado: v.optional(v.number()), // Pode ter raio diferente do padrão
|
||||
// Período de validade (para deslocamentos temporários)
|
||||
dataInicio: v.optional(v.string()), // YYYY-MM-DD
|
||||
dataFim: v.optional(v.string()), // YYYY-MM-DD
|
||||
// Status
|
||||
ativo: v.boolean(),
|
||||
// Metadados
|
||||
criadoPor: v.id("usuarios"),
|
||||
criadoEm: v.number(),
|
||||
})
|
||||
.index("by_funcionario", ["funcionarioId"])
|
||||
.index("by_endereco", ["enderecoMarcacaoId"])
|
||||
.index("by_funcionario_ativo", ["funcionarioId", "ativo"])
|
||||
.index("by_endereco_ativo", ["enderecoMarcacaoId", "ativo"]),
|
||||
|
||||
configuracaoPonto: defineTable({
|
||||
horarioEntrada: v.string(), // HH:mm
|
||||
horarioSaidaAlmoco: v.string(), // HH:mm
|
||||
@@ -1463,6 +1536,9 @@ export default defineSchema({
|
||||
nomeSaida: v.optional(v.string()), // Padrão: "Saída 2"
|
||||
// Ajuste de fuso horário (GMT offset em horas)
|
||||
gmtOffset: v.optional(v.number()), // Padrão: 0 (UTC)
|
||||
// Configurações de geofencing
|
||||
validarLocalizacao: v.optional(v.boolean()), // Habilitar/desabilitar validação de localização
|
||||
toleranciaDistanciaMetros: v.optional(v.number()), // Raio padrão global em metros
|
||||
ativo: v.boolean(),
|
||||
atualizadoPor: v.id("usuarios"),
|
||||
atualizadoEm: v.number(),
|
||||
|
||||
Reference in New Issue
Block a user