Feat controle ponto #35

Merged
deyvisonwanderley merged 7 commits from feat-controle-ponto into master 2025-11-21 15:48:44 +00:00
19 changed files with 5466 additions and 823 deletions

View 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`

View 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.

View File

@@ -34,6 +34,12 @@
funcionarioId && dataHoje ? { funcionarioId, data: dataHoje } : 'skip' funcionarioId && dataHoje ? { funcionarioId, data: dataHoje } : 'skip'
); );
// Query para verificar dispensa ativa
const dispensaQuery = useQuery(
api.pontos.verificarDispensaAtiva,
funcionarioId && dataHoje ? { funcionarioId, data: dataHoje } : 'skip'
);
// Estados // Estados
let mostrandoWebcam = $state(false); let mostrandoWebcam = $state(false);
let registrando = $state(false); let registrando = $state(false);
@@ -150,6 +156,22 @@
async function registrarPonto() { async function registrarPonto() {
if (registrando) return; 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 // Verificar permissões antes de registrar
const permissoes = await verificarPermissoes(); const permissoes = await verificarPermissoes();
if (!permissoes.localizacao || !permissoes.webcam) { if (!permissoes.localizacao || !permissoes.webcam) {
@@ -296,6 +318,22 @@
async function iniciarRegistroComFoto() { async function iniciarRegistroComFoto() {
if (registrando || coletandoInfo) return; 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 // Verificar permissões antes de abrir webcam
const permissoes = await verificarPermissoes(); const permissoes = await verificarPermissoes();
if (!permissoes.localizacao || !permissoes.webcam) { 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(() => { const podeRegistrar = $derived.by(() => {
return !registrando && !coletandoInfo && config !== undefined; return !registrando && !coletandoInfo && config !== undefined && !estaDispensado && temFuncionarioAssociado;
}); });
// Referência para o modal // Referência para o modal
@@ -650,55 +693,67 @@
</script> </script>
<div class="space-y-6"> <div class="space-y-6">
<!-- Relógio Sincronizado --> <!-- Alerta de Funcionário Não Associado -->
<div class="card bg-base-100 shadow-xl"> {#if !temFuncionarioAssociado}
<div class="card-body items-center"> <div class="alert alert-error shadow-lg">
<RelogioSincronizado /> <svg
</div> xmlns="http://www.w3.org/2000/svg"
</div> class="h-6 w-6 shrink-0 stroke-current"
fill="none"
<!-- Mapa de Horários --> viewBox="0 0 24 24"
<div class="card bg-base-100 shadow-xl"> >
<div class="card-body"> <path
<h2 class="card-title"> stroke-linecap="round"
<Clock class="h-5 w-5" /> stroke-linejoin="round"
Horários do Dia stroke-width="2"
</h2> d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
<div class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4"> />
{#each mapaHorarios as horario (horario.tipo)} </svg>
<div <div>
class="card {horario.registrado <h3 class="font-bold">Funcionário Não Associado</h3>
? 'bg-success/10 border-success' <div class="text-sm">
: 'bg-base-200'} border-2" Você não possui um funcionário associado à sua conta.
> <br />
<div class="card-body p-4"> Entre em contato com o administrador do sistema para associar um funcionário à sua conta.
<div class="mb-2 flex items-center justify-between"> </div>
<span class="font-semibold">{horario.label}</span>
{#if horario.registrado}
{#if horario.dentroDoPrazo}
<CheckCircle2 class="text-success h-5 w-5" />
{:else}
<XCircle class="text-error h-5 w-5" />
{/if}
{/if}
</div>
<div class="text-2xl font-bold">{horario.horario}</div>
{#if horario.registrado}
<div class="text-base-content/70 text-sm">
Registrado: {horario.horarioRegistrado}
</div>
{/if}
</div>
</div>
{/each}
</div> </div>
</div> </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 --> <!-- Botões de Registro -->
<div class="card bg-base-100 shadow-xl"> <div class="card bg-base-100 shadow-xl">
<div class="card-body items-center"> <div class="card-body items-center">
<h2 class="card-title mb-4">Registrar Ponto</h2> <h2 class="card-title mb-4">Registrar Ponto</h2>
<div class="mb-6 w-full">
<RelogioSincronizado />
</div>
<div class="flex w-full flex-col items-center gap-4"> <div class="flex w-full flex-col items-center gap-4">
{#if sucesso} {#if sucesso}
<div class="alert alert-success w-full"> <div class="alert alert-success w-full">
@@ -730,6 +785,11 @@
class="btn btn-primary btn-lg" class="btn btn-primary btn-lg"
onclick={iniciarRegistroComFoto} onclick={iniciarRegistroComFoto}
disabled={!podeRegistrar} disabled={!podeRegistrar}
title={!temFuncionarioAssociado
? 'Você não possui funcionário associado à sua conta'
: estaDispensado
? 'Você está dispensado de registrar ponto no momento'
: ''}
> >
{#if registrando} {#if registrando}
<span class="loading loading-spinner loading-sm"></span> <span class="loading loading-spinner loading-sm"></span>
@@ -738,6 +798,12 @@
{:else} {:else}
Registrando... Registrando...
{/if} {/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'} {:else if proximoTipo === 'entrada' || proximoTipo === 'retorno_almoco'}
<LogIn class="h-5 w-5" /> <LogIn class="h-5 w-5" />
Registrar Entrada Registrar Entrada
@@ -750,6 +816,78 @@
</div> </div>
</div> </div>
<!-- Mapa de Horários -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title mb-6">
<Clock class="h-5 w-5" />
Horário Padrão
</h2>
<!-- Linha horizontal com espaçamento uniforme -->
<div class="flex flex-wrap items-stretch justify-between gap-4 md:gap-6">
{#each mapaHorarios as horario (horario.tipo)}
<div class="flex-1 min-w-[140px] max-w-[220px] mx-auto">
<div
class="relative h-full rounded-xl border-2 transition-all duration-300 hover:shadow-lg {horario.registrado
? horario.dentroDoPrazo
? 'bg-gradient-to-br from-success/20 to-success/10 border-success shadow-md'
: 'bg-gradient-to-br from-error/20 to-error/10 border-error shadow-md'
: 'bg-gradient-to-br from-base-200 to-base-300 border-base-300'} p-5"
>
<!-- Status Icon -->
<div class="absolute top-3 right-3">
{#if horario.registrado}
{#if horario.dentroDoPrazo}
<CheckCircle2 class="h-5 w-5 text-success" />
{:else}
<XCircle class="h-5 w-5 text-error" />
{/if}
{:else}
<Clock class="h-5 w-5 text-base-content/30" />
{/if}
</div>
<!-- Label -->
<div class="mb-3">
<span class="text-sm font-semibold text-base-content/80 uppercase tracking-wide">
{horario.label}
</span>
</div>
<!-- Horário Padrão -->
<div class="mb-2">
<div class="text-3xl font-bold text-primary font-mono">
{horario.horario}
</div>
</div>
<!-- Horário Registrado (se houver) -->
{#if horario.registrado}
<div class="mt-3 pt-3 border-t border-base-content/10">
<div class="flex items-center gap-2">
<div class="text-xs font-medium text-base-content/60">
Registrado:
</div>
<div class="text-sm font-bold text-base-content">
{horario.horarioRegistrado}
</div>
</div>
</div>
{:else}
<div class="mt-3 pt-3 border-t border-base-content/10">
<div class="text-xs text-base-content/40 italic">
Aguardando registro
</div>
</div>
{/if}
</div>
</div>
{/each}
</div>
</div>
</div>
<!-- Histórico e Saldo do Dia --> <!-- Histórico e Saldo do Dia -->
{#if historicoSaldo && registrosOrdenados.length > 0} {#if historicoSaldo && registrosOrdenados.length > 0}
<div class="card bg-base-100 shadow-xl"> <div class="card bg-base-100 shadow-xl">
@@ -780,57 +918,224 @@
</div> </div>
</div> </div>
<!-- Lista de Registros --> <!-- Timeline de Registros -->
<div class="divider"></div> <div class="divider"></div>
<div class="space-y-2"> <div class="space-y-4">
<h3 class="font-semibold">Registros Realizados</h3> <h3 class="font-semibold">Timeline do Dia</h3>
<div class="space-y-3">
{#each registrosOrdenados as registro (registro._id)} <!-- Timeline Visual com horários padrão e registros reais -->
<div class="card bg-base-200"> <div class="relative">
<div class="card-body p-4"> <!-- Linha vertical central da timeline -->
<div class="flex items-start justify-between gap-4"> <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>
<div class="flex-1">
<div class="mb-1 flex items-center gap-2"> <!-- Container com duas colunas -->
<span class="font-semibold"> <div class="grid grid-cols-2 gap-4 relative">
{config <!-- Coluna Entrada -->
? getTipoRegistroLabel(registro.tipo, { <div class="space-y-4 pr-2">
nomeEntrada: config.nomeEntrada, <div class="sticky top-0 z-10 bg-base-100 pb-3 mb-2 border-b border-primary/20">
nomeSaidaAlmoco: config.nomeSaidaAlmoco, <h4 class="text-lg font-bold text-primary text-center flex items-center justify-center gap-2">
nomeRetornoAlmoco: config.nomeRetornoAlmoco, <LogIn class="h-5 w-5" />
nomeSaida: config.nomeSaida, Entradas
}) </h4>
: getTipoRegistroLabel(registro.tipo)} </div>
</span>
{#if registro.dentroDoPrazo} {#each registrosOrdenados.filter(r => r.tipo === 'entrada' || r.tipo === 'retorno_almoco') as registro (registro._id)}
<CheckCircle2 class="h-4 w-4 text-success" /> <div class="relative">
{:else} <!-- Linha horizontal conectando à timeline -->
<XCircle class="h-4 w-4 text-error" /> <div class="absolute right-0 top-6 w-full h-0.5 bg-base-300/50" style="width: calc(100% - 0.5rem);"></div>
{/if}
</div> <!-- Card do registro -->
<p class="text-lg font-bold"> <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">
{formatarHoraPonto(registro.hora, registro.minuto)} <div class="card-body p-4">
</p> <!-- Tipo de registro e status -->
{#if registro.justificativa} <div class="flex items-center gap-2 mb-2">
<div class="mt-2 rounded bg-base-300 p-2"> {#if registro.dentroDoPrazo}
<p class="text-xs font-semibold opacity-70">Justificativa:</p> <CheckCircle2 class="h-4 w-4 text-success flex-shrink-0" />
<p class="text-sm">{registro.justificativa}</p> {: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> </div>
{/if}
</div> <!-- Horário registrado -->
<div class="flex-shrink-0"> <p class="text-3xl font-bold text-primary mb-1">
<button {formatarHoraPonto(registro.hora, registro.minuto)}
class="btn btn-sm btn-outline btn-primary gap-2" </p>
onclick={() => imprimirComprovante(registro._id)}
title="Imprimir Comprovante" <!-- Comparação com horário esperado -->
> {#if config}
<Printer class="h-4 w-4" /> {@const horarioEsperado = registro.tipo === 'entrada' ? config.horarioEntrada : config.horarioRetornoAlmoco}
Imprimir Comprovante {@const [horaEsperada, minutoEsperado] = horarioEsperado.split(':').map(Number)}
</button> {@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> </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> </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> </div>
</div> </div>

View File

@@ -17,36 +17,45 @@
async function atualizarTempo() { async function atualizarTempo() {
try { try {
const config = await client.query(api.configuracaoRelogio.obterConfiguracao, {}); const config = await client.query(api.configuracaoRelogio.obterConfiguracao, {});
const gmtOffset = config.gmtOffset ?? 0;
let timestampBase: number;
if (config.usarServidorExterno) { if (config.usarServidorExterno) {
try { try {
const resultado = await client.action(api.configuracaoRelogio.sincronizarTempo, {}); const resultado = await client.action(api.configuracaoRelogio.sincronizarTempo, {});
if (resultado.sucesso && resultado.timestamp) { if (resultado.sucesso && resultado.timestamp) {
tempoAtual = new Date(resultado.timestamp); timestampBase = resultado.timestamp;
sincronizado = true; sincronizado = true;
usandoServidorExterno = resultado.usandoServidorExterno || false; usandoServidorExterno = resultado.usandoServidorExterno || false;
offsetSegundos = resultado.offsetSegundos || 0; offsetSegundos = resultado.offsetSegundos || 0;
erro = null; erro = null;
} else {
throw new Error('Falha ao sincronizar');
} }
} catch (error) { } catch (error) {
console.warn('Erro ao sincronizar:', error); console.warn('Erro ao sincronizar:', error);
if (config.fallbackParaPC) { if (config.fallbackParaPC) {
tempoAtual = new Date(obterTempoPC()); timestampBase = obterTempoPC();
sincronizado = false; sincronizado = false;
usandoServidorExterno = false; usandoServidorExterno = false;
erro = 'Usando relógio do PC (falha na sincronização)'; erro = 'Usando relógio do PC (falha na sincronização)';
} else { } else {
erro = 'Falha ao sincronizar tempo'; throw error;
} }
} }
} else { } else {
// Usar tempo do servidor Convex // Usar tempo do servidor Convex
const tempoServidor = await obterTempoServidor(client); timestampBase = await obterTempoServidor(client);
tempoAtual = new Date(tempoServidor);
sincronizado = true; sincronizado = true;
usandoServidorExterno = false; usandoServidorExterno = false;
erro = null; erro = null;
} }
// Aplicar GMT offset ao timestamp
// O timestamp está em UTC, adicionar o offset em horas
const timestampAjustado = timestampBase + (gmtOffset * 60 * 60 * 1000);
tempoAtual = new Date(timestampAjustado);
} catch (error) { } catch (error) {
console.error('Erro ao obter tempo:', error); console.error('Erro ao obter tempo:', error);
tempoAtual = new Date(obterTempoPC()); tempoAtual = new Date(obterTempoPC());

View File

@@ -15,6 +15,13 @@ export interface InformacoesDispositivo {
latitude?: number; latitude?: number;
longitude?: number; longitude?: number;
precisao?: number; precisao?: number;
altitude?: number | null;
altitudeAccuracy?: number | null;
heading?: number | null;
speed?: number | null;
confiabilidadeGPS?: number; // 0-1
suspeitaSpoofing?: boolean;
motivoSuspeita?: string;
endereco?: string; endereco?: string;
cidade?: string; cidade?: string;
estado?: string; estado?: string;
@@ -230,12 +237,289 @@ function obterInformacoesMemoria(): string {
} }
/** /**
* Obtém localização via GPS com múltiplas tentativas * Calcula distância entre duas coordenadas (fórmula de Haversine)
* Retorna distância em metros
*/
function calcularDistancia(
lat1: number,
lon1: number,
lat2: number,
lon2: number
): number {
const R = 6371000; // Raio da Terra em metros
const dLat = ((lat2 - lat1) * Math.PI) / 180;
const dLon = ((lon2 - lon1) * Math.PI) / 180;
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos((lat1 * Math.PI) / 180) *
Math.cos((lat2 * Math.PI) / 180) *
Math.sin(dLon / 2) *
Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
/**
* Obtém timezone aproximado por coordenadas
*/
function obterTimezonePorCoordenadas(latitude: number, longitude: number): string {
// Pernambuco está em UTC-3 (America/Recife)
if (longitude >= -45 && longitude <= -30 && latitude >= -10 && latitude <= 5) {
return 'America/Recife'; // UTC-3
}
// Fallback: usar timezone do sistema
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
} catch {
return 'America/Recife'; // Default
}
}
/**
* Captura uma única leitura de localização com todas as propriedades disponíveis
*/
async function capturarLocalizacaoUnica(
enableHighAccuracy: boolean = true,
timeout: number = 10000
): Promise<{
latitude?: number;
longitude?: number;
precisao?: number;
altitude?: number | null;
altitudeAccuracy?: number | null;
heading?: number | null;
speed?: number | null;
timestamp?: number;
confiabilidade: number; // 0-1
}> {
return new Promise((resolve) => {
if (typeof navigator === 'undefined' || !navigator.geolocation) {
resolve({ confiabilidade: 0 });
return;
}
const timeoutId = setTimeout(() => {
resolve({ confiabilidade: 0 });
}, timeout + 1000);
navigator.geolocation.getCurrentPosition(
(position) => {
clearTimeout(timeoutId);
const coords = position.coords;
const { latitude, longitude, accuracy } = coords;
// Validar coordenadas básicas
if (
isNaN(latitude) ||
isNaN(longitude) ||
latitude === 0 ||
longitude === 0 ||
latitude < -90 ||
latitude > 90 ||
longitude < -180 ||
longitude > 180
) {
resolve({ confiabilidade: 0 });
return;
}
// Calcular score de confiabilidade baseado em propriedades do GPS real
const sinaisGPSReal = {
temAltitude: coords.altitude !== null && coords.altitude !== 0,
temAltitudeAccuracy: coords.altitudeAccuracy !== null && coords.altitudeAccuracy > 0,
temHeading: coords.heading !== null && !isNaN(coords.heading),
temSpeed: coords.speed !== null && !isNaN(coords.speed),
precisaoBoa: accuracy < 20, // GPS real geralmente < 20m
precisaoMedia: accuracy >= 20 && accuracy < 100,
timestampPreciso: position.timestamp > 0
};
// Calcular confiabilidade: cada sinal adiciona pontos
let pontos = 0;
const maxPontos = 7;
if (sinaisGPSReal.temAltitude) pontos += 1;
if (sinaisGPSReal.temAltitudeAccuracy) pontos += 1;
if (sinaisGPSReal.temHeading) pontos += 0.5;
if (sinaisGPSReal.temSpeed) pontos += 0.5;
if (sinaisGPSReal.precisaoBoa) pontos += 2;
if (sinaisGPSReal.precisaoMedia) pontos += 1;
if (sinaisGPSReal.timestampPreciso) pontos += 1;
const confiabilidade = Math.min(pontos / maxPontos, 1);
resolve({
latitude,
longitude,
precisao: accuracy,
altitude: coords.altitude ?? null,
altitudeAccuracy: coords.altitudeAccuracy ?? null,
heading: coords.heading ?? null,
speed: coords.speed ?? null,
timestamp: position.timestamp,
confiabilidade
});
},
(error) => {
clearTimeout(timeoutId);
console.warn('Erro ao obter localização:', error.code, error.message);
resolve({ confiabilidade: 0 });
},
{
enableHighAccuracy,
timeout,
maximumAge: 0 // Sempre obter nova leitura
}
);
});
}
/**
* Obtém localização via GPS com múltiplas leituras para detectar spoofing
* Apps de spoofing geralmente retornam valores idênticos em todas as leituras
*/
async function obterLocalizacaoMultipla(): Promise<{
latitude?: number;
longitude?: number;
precisao?: number;
altitude?: number | null;
altitudeAccuracy?: number | null;
heading?: number | null;
speed?: number | null;
confiabilidade: number; // 0-1
suspeitaSpoofing: boolean;
motivoSuspeita?: string;
}> {
if (typeof navigator === 'undefined' || !navigator.geolocation) {
return { confiabilidade: 0, suspeitaSpoofing: true, motivoSuspeita: 'Geolocalização não suportada' };
}
// Capturar 3 leituras com intervalo de 2 segundos entre elas
const leituras: Array<{
lat: number;
lon: number;
precisao: number;
altitude: number | null;
confiabilidade: number;
}> = [];
for (let i = 0; i < 3; i++) {
const leitura = await capturarLocalizacaoUnica(true, 8000);
if (leitura.latitude && leitura.longitude && leitura.confiabilidade > 0) {
leituras.push({
lat: leitura.latitude,
lon: leitura.longitude,
precisao: leitura.precisao || 999,
altitude: leitura.altitude ?? null,
confiabilidade: leitura.confiabilidade
});
}
// Aguardar 2 segundos entre leituras (exceto na última)
if (i < 2) {
await new Promise((resolve) => setTimeout(resolve, 2000));
}
}
if (leituras.length === 0) {
return { confiabilidade: 0, suspeitaSpoofing: true, motivoSuspeita: 'Não foi possível obter localização' };
}
// Se tivermos menos de 2 leituras, usar única leitura com baixa confiança
if (leituras.length < 2) {
const unica = leituras[0];
return {
latitude: unica.lat,
longitude: unica.lon,
precisao: unica.precisao,
altitude: unica.altitude,
altitudeAccuracy: null,
heading: null,
speed: null,
confiabilidade: unica.confiabilidade * 0.5, // Reduzir confiança por ter apenas 1 leitura
suspeitaSpoofing: true,
motivoSuspeita: 'Apenas uma leitura obtida'
};
}
// Verificar se todas as leituras são idênticas (suspeito de spoofing)
const primeiraLeitura = leituras[0];
const todasIguais = leituras.every(
(l) =>
Math.abs(l.lat - primeiraLeitura.lat) < 0.00001 && // ~1 metro
Math.abs(l.lon - primeiraLeitura.lon) < 0.00001
);
if (todasIguais && leituras.length === 3) {
// GPS real varia alguns metros, se todas são idênticas pode ser spoofing
return {
latitude: primeiraLeitura.lat,
longitude: primeiraLeitura.lon,
precisao: primeiraLeitura.precisao,
altitude: primeiraLeitura.altitude,
altitudeAccuracy: null,
heading: null,
speed: null,
confiabilidade: primeiraLeitura.confiabilidade * 0.4, // Reduzir drasticamente confiança
suspeitaSpoofing: true,
motivoSuspeita: 'Todas as leituras são idênticas (GPS real varia alguns metros)'
};
}
// Calcular média das leituras e variância
const mediaLat = leituras.reduce((sum, l) => sum + l.lat, 0) / leituras.length;
const mediaLon = leituras.reduce((sum, l) => sum + l.lon, 0) / leituras.length;
const mediaConfianca = leituras.reduce((sum, l) => sum + l.confiabilidade, 0) / leituras.length;
// Calcular distância máxima entre leituras
let distanciaMaxima = 0;
for (let i = 0; i < leituras.length; i++) {
for (let j = i + 1; j < leituras.length; j++) {
const dist = calcularDistancia(
leituras[i].lat,
leituras[i].lon,
leituras[j].lat,
leituras[j].lon
);
distanciaMaxima = Math.max(distanciaMaxima, dist);
}
}
// Se distância máxima for muito grande (> 100m), pode indicar problemas
const suspeitoPorDistancia = distanciaMaxima > 100;
return {
latitude: mediaLat,
longitude: mediaLon,
precisao: primeiraLeitura.precisao,
altitude: primeiraLeitura.altitude,
altitudeAccuracy: null,
heading: null,
speed: null,
confiabilidade: suspeitoPorDistancia ? mediaConfianca * 0.6 : mediaConfianca,
suspeitaSpoofing: suspeitoPorDistancia,
motivoSuspeita: suspeitoPorDistancia
? `Variação muito grande entre leituras (${Math.round(distanciaMaxima)}m)`
: undefined
};
}
/**
* Obtém localização via GPS com múltiplas tentativas e validações anti-spoofing
*/ */
async function obterLocalizacao(): Promise<{ async function obterLocalizacao(): Promise<{
latitude?: number; latitude?: number;
longitude?: number; longitude?: number;
precisao?: number; precisao?: number;
altitude?: number | null;
altitudeAccuracy?: number | null;
heading?: number | null;
speed?: number | null;
confiabilidadeGPS?: number;
suspeitaSpoofing?: boolean;
motivoSuspeita?: string;
endereco?: string; endereco?: string;
cidade?: string; cidade?: string;
estado?: string; estado?: string;
@@ -246,127 +530,95 @@ async function obterLocalizacao(): Promise<{
return {}; return {};
} }
// Tentar múltiplas estratégias // Usar múltiplas leituras para detectar spoofing
const estrategias = [ const localizacaoMultipla = await obterLocalizacaoMultipla();
// Estratégia 1: Alta precisão (mais lento, mas mais preciso)
{
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
}
];
for (const options of estrategias) { if (!localizacaoMultipla.latitude || !localizacaoMultipla.longitude) {
try { console.warn('Não foi possível obter localização');
const resultado = await new Promise<{ return {
latitude?: number; confiabilidadeGPS: 0,
longitude?: number; suspeitaSpoofing: true,
precisao?: number; motivoSuspeita: 'Não foi possível obter localização'
endereco?: string; };
cidade?: string; }
estado?: string;
pais?: string;
}>((resolve) => {
const timeout = setTimeout(() => {
resolve({});
}, options.timeout + 1000);
navigator.geolocation.getCurrentPosition( const { latitude, longitude, precisao, altitude, altitudeAccuracy, heading, speed, confiabilidade, suspeitaSpoofing, motivoSuspeita } = localizacaoMultipla;
async (position) => {
clearTimeout(timeout);
const { latitude, longitude, accuracy } = position.coords;
// Validar coordenadas // Tentar obter endereço via reverse geocoding
if (isNaN(latitude) || isNaN(longitude) || latitude === 0 || longitude === 0) { let endereco = '';
resolve({}); let cidade = '';
return; let estado = '';
} let pais = '';
// Tentar obter endereço via reverse geocoding try {
let endereco = ''; const response = await fetch(
let cidade = ''; `https://nominatim.openstreetmap.org/reverse?format=json&lat=${latitude}&lon=${longitude}&zoom=18&addressdetails=1`,
let estado = ''; {
let pais = ''; headers: {
'User-Agent': 'SGSE-App/1.0'
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;
} }
} catch (error) { );
console.warn('Erro na estratégia de geolocalização:', error); if (response.ok) {
continue; 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.log('Localização obtida com validações:', {
console.warn('Não foi possível obter localização após todas as tentativas'); latitude,
return {}; 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.latitude = localizacao.latitude;
informacoes.longitude = localizacao.longitude; informacoes.longitude = localizacao.longitude;
informacoes.precisao = localizacao.precisao; informacoes.precisao = localizacao.precisao;
informacoes.altitude = localizacao.altitude ?? null;
informacoes.altitudeAccuracy = localizacao.altitudeAccuracy ?? null;
informacoes.heading = localizacao.heading ?? null;
informacoes.speed = localizacao.speed ?? null;
informacoes.confiabilidadeGPS = localizacao.confiabilidadeGPS;
informacoes.suspeitaSpoofing = localizacao.suspeitaSpoofing;
informacoes.motivoSuspeita = localizacao.motivoSuspeita;
informacoes.endereco = localizacao.endereco; informacoes.endereco = localizacao.endereco;
informacoes.cidade = localizacao.cidade; informacoes.cidade = localizacao.cidade;
informacoes.estado = localizacao.estado; informacoes.estado = localizacao.estado;

View File

@@ -1,8 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte';
import { useQuery, useConvexClient } from 'convex-svelte'; import { useQuery, useConvexClient } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api'; import { api } from '@sgse-app/backend/convex/_generated/api';
import { Clock, 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 type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
@@ -11,21 +10,32 @@
// Estados // Estados
let funcionariosSelecionados = $state<Id<'funcionarios'>[]>([]); let funcionariosSelecionados = $state<Id<'funcionarios'>[]>([]);
let modoCriacao = $state(false); let modoCriacao = $state(false);
let mostrandoModalExcluir = $state(false);
let dispensaParaExcluir = $state<Id<'dispensasRegistro'> | null>(null);
// Formulário // Formulário
let dataInicio = $state(new Date().toISOString().split('T')[0]!); let dataInicio = $state(new Date().toISOString().split('T')[0]!);
let horaInicio = $state(8); let horaInicioTime = $state('08:00');
let minutoInicio = $state(0);
let dataFim = $state(new Date().toISOString().split('T')[0]!); let dataFim = $state(new Date().toISOString().split('T')[0]!);
let horaFim = $state(18); let horaFimTime = $state('18:00');
let minutoFim = $state(0);
let motivo = $state(''); let motivo = $state('');
let isento = $state(false); 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 // Queries
const subordinadosQuery = useQuery(api.times.listarSubordinadosDoGestorAtual, {}); const subordinadosQuery = useQuery(api.times.listarSubordinadosDoGestorAtual, {});
const dispensasQuery = useQuery(api.pontos.listarDispensas, { 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 || []); const subordinados = $derived(subordinadosQuery?.data || []);
@@ -52,11 +62,9 @@
modoCriacao = true; modoCriacao = true;
funcionariosSelecionados = []; funcionariosSelecionados = [];
dataInicio = new Date().toISOString().split('T')[0]!; dataInicio = new Date().toISOString().split('T')[0]!;
horaInicio = 8; horaInicioTime = '08:00';
minutoInicio = 0;
dataFim = new Date().toISOString().split('T')[0]!; dataFim = new Date().toISOString().split('T')[0]!;
horaFim = 18; horaFimTime = '18:00';
minutoFim = 0;
motivo = ''; motivo = '';
isento = false; isento = false;
} }
@@ -99,11 +107,11 @@
client.mutation(api.pontos.criarDispensaRegistro, { client.mutation(api.pontos.criarDispensaRegistro, {
funcionarioId, funcionarioId,
dataInicio, dataInicio,
horaInicio, horaInicio: horaInicio.hora,
minutoInicio, minutoInicio: horaInicio.minuto,
dataFim, dataFim,
horaFim, horaFim: horaFim.hora,
minutoFim, minutoFim: horaFim.minuto,
motivo, motivo,
isento, isento,
}) })
@@ -121,15 +129,26 @@
} }
} }
async function removerDispensa(dispensaId: Id<'dispensasRegistro'>) { function abrirModalExcluir(dispensaId: Id<'dispensasRegistro'>) {
if (!confirm('Deseja realmente remover esta dispensa?')) return; dispensaParaExcluir = dispensaId;
mostrandoModalExcluir = true;
}
function fecharModalExcluir() {
mostrandoModalExcluir = false;
dispensaParaExcluir = null;
}
async function confirmarRemoverDispensa() {
if (!dispensaParaExcluir) return;
try { try {
await client.mutation(api.pontos.removerDispensaRegistro, { await client.mutation(api.pontos.removerDispensaRegistro, {
dispensaId, dispensaId: dispensaParaExcluir,
}); });
toast.success('Dispensa removida com sucesso'); toast.success('Dispensa removida com sucesso');
fecharModalExcluir();
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error); const errorMessage = error instanceof Error ? error.message : String(error);
toast.error(`Erro ao remover dispensa: ${errorMessage}`); toast.error(`Erro ao remover dispensa: ${errorMessage}`);
@@ -164,19 +183,27 @@
<!-- Formulário de Criação --> <!-- Formulário de Criação -->
{#if modoCriacao} {#if modoCriacao}
<div class="card bg-base-100 shadow-xl mb-6"> <div class="card bg-base-100 shadow-xl mb-6">
<div class="card-body"> <div class="card-body space-y-6">
<h2 class="card-title mb-4">Criar Dispensa de Registro</h2> <h2 class="card-title border-b pb-3 text-xl">Criar Dispensa de Registro</h2>
<!-- Seleção de Funcionários --> <!-- Seleção de Funcionários -->
<div class="form-control mb-4"> <div class="form-control">
<label class="label"> <label class="label">
<span class="label-text font-medium">Funcionários</span> <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> </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} {#each funcionarios as funcionario}
<label class="label cursor-pointer"> <label class="flex items-center justify-between p-3 rounded-lg hover:bg-base-200 transition-colors cursor-pointer">
<span class="label-text"> <span class="label-text font-medium">
{funcionario.nome} {funcionario.matricula ? `(${funcionario.matricula})` : ''} {funcionario.nome}
{#if funcionario.matricula}
<span class="text-base-content/60 ml-2">({funcionario.matricula})</span>
{/if}
</span> </span>
<input <input
type="checkbox" type="checkbox"
@@ -186,90 +213,74 @@
/> />
</label> </label>
{/each} {/each}
{#if funcionarios.length === 0}
<div class="text-center py-4 text-base-content/60">
Nenhum funcionário disponível
</div>
{/if}
</div> </div>
</div> </div>
<!-- Período -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control"> <div class="form-control">
<label class="label"> <label class="label">
<span class="label-text font-medium">Data Início</span> <span class="label-text font-medium">Data Início</span>
</label> </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>
<div class="form-control"> <div class="form-control">
<label class="label"> <label class="label">
<span class="label-text font-medium">Hora Início</span> <span class="label-text font-medium">Hora Início</span>
</label> </label>
<div class="flex gap-2"> <input type="time" class="input input-bordered w-full" bind:value={horaInicioTime} />
<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>
</div> </div>
<div class="form-control"> <div class="form-control">
<label class="label"> <label class="label">
<span class="label-text font-medium">Data Fim</span> <span class="label-text font-medium">Data Fim</span>
</label> </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>
<div class="form-control"> <div class="form-control">
<label class="label"> <label class="label">
<span class="label-text font-medium">Hora Fim</span> <span class="label-text font-medium">Hora Fim</span>
</label> </label>
<div class="flex gap-2"> <input type="time" class="input input-bordered w-full" bind:value={horaFimTime} />
<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>
</div> </div>
</div> </div>
<div class="flex gap-2 mt-4"> <!-- Motivo -->
<button class="btn btn-primary gap-2" onclick={salvarDispensa}> <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" /> <Plus class="h-4 w-4" />
Criar Dispensa Criar Dispensa
</button> </button>
@@ -342,7 +353,7 @@
<td> <td>
<button <button
class="btn btn-sm btn-error gap-2" class="btn btn-sm btn-error gap-2"
onclick={() => removerDispensa(dispensa._id)} onclick={() => abrirModalExcluir(dispensa._id)}
> >
<Trash2 class="h-4 w-4" /> <Trash2 class="h-4 w-4" />
Remover Remover
@@ -356,5 +367,32 @@
{/if} {/if}
</div> </div>
</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> </div>

View File

@@ -65,9 +65,9 @@
$: needsScroll = filtered.length > 8; $: needsScroll = filtered.length > 8;
</script> </script>
<main class="container mx-auto px-4 py-4"> <main class="container mx-auto px-4 py-4 max-w-7xl flex flex-col" style="height: calc(100vh - 8rem); min-height: 600px;">
<!-- Breadcrumb --> <!-- Breadcrumb -->
<div class="breadcrumbs mb-4 text-sm"> <div class="breadcrumbs mb-4 text-sm flex-shrink-0">
<ul> <ul>
<li><a href={resolve('/recursos-humanos')} class="text-primary hover:underline">Recursos Humanos</a></li> <li><a href={resolve('/recursos-humanos')} class="text-primary hover:underline">Recursos Humanos</a></li>
<li>Funcionários</li> <li>Funcionários</li>
@@ -75,7 +75,7 @@
</div> </div>
<!-- Cabeçalho --> <!-- Cabeçalho -->
<div class="mb-6"> <div class="mb-6 flex-shrink-0">
<div class="flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center"> <div class="flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<div class="rounded-xl bg-blue-500/20 p-3"> <div class="rounded-xl bg-blue-500/20 p-3">
@@ -99,7 +99,7 @@
<p class="text-base-content/70">Gerencie os funcionários da secretaria</p> <p class="text-base-content/70">Gerencie os funcionários da secretaria</p>
</div> </div>
</div> </div>
<button class="btn btn-primary btn-lg gap-2" onclick={navCadastro}> <button class="btn btn-primary btn-lg gap-2 shadow-md hover:shadow-lg transition-all" onclick={navCadastro}>
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5" class="h-5 w-5"
@@ -118,7 +118,7 @@
</div> </div>
<!-- Filtros --> <!-- Filtros -->
<div class="card bg-base-100 mb-6 shadow-xl"> <div class="card bg-base-100/90 backdrop-blur-sm border border-base-300 mb-4 shadow-xl flex-shrink-0">
<div class="card-body"> <div class="card-body">
<h2 class="card-title mb-4 text-lg"> <h2 class="card-title mb-4 text-lg">
<svg <svg
@@ -223,82 +223,133 @@
</div> </div>
</div> </div>
<!-- Tabela de Funcionários --> <!-- Container da Tabela com altura responsiva -->
<div class="card bg-base-100 shadow-xl"> <div class="flex-1 flex flex-col min-h-0">
<div class="card-body p-0"> <!-- Tabela de Funcionários -->
<div class="overflow-x-auto"> <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="overflow-y-auto" style="max-height: {filtered.length > 8 ? '600px' : 'none'};"> <div class="card-body p-0 flex-1 flex flex-col min-h-0">
<table class="table-zebra table w-full"> <!-- Container com scroll -->
<thead class="bg-base-200 sticky top-0 z-10"> <div class="flex-1 overflow-hidden flex flex-col">
<tr> <div class="overflow-x-auto flex-1 overflow-y-auto">
<th class="font-bold">Nome</th> <table class="table table-zebra w-full">
<th class="font-bold">CPF</th> <thead class="sticky top-0 z-10 shadow-md bg-gradient-to-r from-base-300 to-base-200">
<th class="font-bold">Matrícula</th> <tr>
<th class="font-bold">Tipo</th> <th class="whitespace-nowrap font-bold text-base-content border-b border-base-400">Nome</th>
<th class="font-bold">Cidade</th> <th class="whitespace-nowrap font-bold text-base-content border-b border-base-400">CPF</th>
<th class="font-bold">UF</th> <th class="whitespace-nowrap font-bold text-base-content border-b border-base-400">Matrícula</th>
<th class="text-right font-bold">Ações</th> <th class="whitespace-nowrap font-bold text-base-content border-b border-base-400">Tipo</th>
</tr> <th class="whitespace-nowrap font-bold text-base-content border-b border-base-400">Cidade</th>
</thead> <th class="whitespace-nowrap font-bold text-base-content border-b border-base-400">UF</th>
<tbody> <th class="text-right whitespace-nowrap font-bold text-base-content border-b border-base-400">Ações</th>
{#each filtered as f} </tr>
<tr class="hover"> </thead>
<td class="font-medium">{f.nome}</td> <tbody>
<td>{f.cpf}</td> {#if filtered.length === 0}
<td>{f.matricula}</td> <tr>
<td>{f.simboloTipo}</td> <td colspan="7" class="text-center py-12">
<td>{f.cidade}</td> <div class="flex flex-col items-center justify-center gap-4">
<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)}
>
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5" class="h-16 w-16 text-base-content/30"
viewBox="0 0 20 20" fill="none"
fill="currentColor" viewBox="0 0 24 24"
><path stroke="currentColor"
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> <path
<ul stroke-linecap="round"
class="dropdown-content menu bg-base-100 rounded-box border-base-300 z-10 w-52 border p-2 shadow-lg" stroke-linejoin="round"
> stroke-width="1.5"
<li> 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"
<a href={`/recursos-humanos/funcionarios/${f._id}`}>Ver Detalhes</a> />
</li> </svg>
<li> <div class="text-base-content/60 text-center">
<a href={`/recursos-humanos/funcionarios/${f._id}/editar`}>Editar</a> <p class="font-semibold text-lg mb-1">Nenhum funcionário encontrado</p>
</li> <p class="text-sm">
<li> {#if filtroNome || filtroCPF || filtroMatricula || filtroTipo}
<a href={`/recursos-humanos/funcionarios/${f._id}/documentos`} Tente ajustar os filtros ou
>Ver Documentos</a {/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> <svg
<li> xmlns="http://www.w3.org/2000/svg"
<button onclick={() => openPrintModal(f._id)}>Imprimir Ficha</button> class="h-5 w-5"
</li> viewBox="0 0 20 20"
</ul> fill="currentColor"
</div> >
</td> <path
</tr> 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"
{/each} />
</tbody> </svg>
</table> </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>
</div> </div>
</div>
<!-- Informação sobre resultados --> <!-- Informação sobre resultados -->
<div class="text-base-content/70 mt-4 text-center text-sm"> <div class="text-base-content/70 mt-3 text-center text-sm flex-shrink-0 border-t border-base-300 pt-3 bg-base-100/50">
Exibindo {filtered.length} de {list.length} funcionário(s) Exibindo <span class="font-semibold">{filtered.length}</span> de <span class="font-semibold">{list.length}</span> funcionário(s)
</div>
</div> </div>
<!-- Modal de Impressão --> <!-- Modal de Impressão -->

View File

@@ -15,6 +15,7 @@
APOSENTADO_OPTIONS, APOSENTADO_OPTIONS,
} from "$lib/utils/constants"; } from "$lib/utils/constants";
import PrintModal from "$lib/components/PrintModal.svelte"; import PrintModal from "$lib/components/PrintModal.svelte";
import { MapPin } from "lucide-svelte";
const client = useConvexClient(); const client = useConvexClient();
@@ -203,6 +204,16 @@
</svg> </svg>
Imprimir Ficha Imprimir Ficha
</button> </button>
<button
class="btn btn-info gap-2"
onclick={() =>
goto(
resolve(`/recursos-humanos/funcionarios/${funcionarioId}/enderecos-marcacao`),
)}
>
<MapPin class="h-5 w-5" />
Endereços de Marcação
</button>
</div> </div>
</div> </div>
</div> </div>

View File

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

View File

@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import { useQuery, useConvexClient } from 'convex-svelte'; import { useQuery, useConvexClient } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api'; import { api } from '@sgse-app/backend/convex/_generated/api';
import { Clock, Save, CheckCircle2 } from 'lucide-svelte'; import { Clock, Save, CheckCircle2, MapPin } from 'lucide-svelte';
import { resolve } from '$app/paths';
const client = useConvexClient(); const client = useConvexClient();
const configQuery = useQuery(api.configuracaoPonto.obterConfiguracao, {}); const configQuery = useQuery(api.configuracaoPonto.obterConfiguracao, {});
@@ -93,6 +94,13 @@
<p class="text-base-content/60 mt-1">Configure os horários de trabalho e tolerâncias</p> <p class="text-base-content/60 mt-1">Configure os horários de trabalho e tolerâncias</p>
</div> </div>
</div> </div>
<a
href={resolve('/ti/configuracoes-ponto/enderecos')}
class="btn btn-secondary gap-2"
>
<MapPin class="h-5 w-5" />
Endereços de Marcação
</a>
</div> </div>
<!-- Mensagens --> <!-- Mensagens -->

View File

@@ -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&region=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>

View File

@@ -29,8 +29,10 @@ import type * as cursos from "../cursos.js";
import type * as dashboard from "../dashboard.js"; import type * as dashboard from "../dashboard.js";
import type * as documentos from "../documentos.js"; import type * as documentos from "../documentos.js";
import type * as email from "../email.js"; import type * as email from "../email.js";
import type * as enderecosMarcacao from "../enderecosMarcacao.js";
import type * as empresas from "../empresas.js"; import type * as empresas from "../empresas.js";
import type * as ferias from "../ferias.js"; import type * as ferias from "../ferias.js";
import type * as funcionarioEnderecos from "../funcionarioEnderecos.js";
import type * as funcionarios from "../funcionarios.js"; import type * as funcionarios from "../funcionarios.js";
import type * as healthCheck from "../healthCheck.js"; import type * as healthCheck from "../healthCheck.js";
import type * as http from "../http.js"; import type * as http from "../http.js";
@@ -82,8 +84,10 @@ declare const fullApi: ApiFromModules<{
dashboard: typeof dashboard; dashboard: typeof dashboard;
documentos: typeof documentos; documentos: typeof documentos;
email: typeof email; email: typeof email;
enderecosMarcacao: typeof enderecosMarcacao;
empresas: typeof empresas; empresas: typeof empresas;
ferias: typeof ferias; ferias: typeof ferias;
funcionarioEnderecos: typeof funcionarioEnderecos;
funcionarios: typeof funcionarios; funcionarios: typeof funcionarios;
healthCheck: typeof healthCheck; healthCheck: typeof healthCheck;
http: typeof http; http: typeof http;

View File

@@ -34,17 +34,21 @@ export const obterConfiguracao = query({
nomeSaidaAlmoco: 'Saída 1', nomeSaidaAlmoco: 'Saída 1',
nomeRetornoAlmoco: 'Entrada 2', nomeRetornoAlmoco: 'Entrada 2',
nomeSaida: 'Saída 2', nomeSaida: 'Saída 2',
validarLocalizacao: true,
toleranciaDistanciaMetros: 100,
ativo: false, ativo: false,
}; };
} }
// Garantir que os nomes padrão estejam definidos // Garantir que os nomes padrão e valores padrão estejam definidos
return { return {
...config, ...config,
nomeEntrada: config.nomeEntrada || 'Entrada 1', nomeEntrada: config.nomeEntrada || 'Entrada 1',
nomeSaidaAlmoco: config.nomeSaidaAlmoco || 'Saída 1', nomeSaidaAlmoco: config.nomeSaidaAlmoco || 'Saída 1',
nomeRetornoAlmoco: config.nomeRetornoAlmoco || 'Entrada 2', nomeRetornoAlmoco: config.nomeRetornoAlmoco || 'Entrada 2',
nomeSaida: config.nomeSaida || 'Saída 2', nomeSaida: config.nomeSaida || 'Saída 2',
validarLocalizacao: config.validarLocalizacao ?? true,
toleranciaDistanciaMetros: config.toleranciaDistanciaMetros ?? 100,
}; };
}, },
}); });
@@ -63,6 +67,8 @@ export const salvarConfiguracao = mutation({
nomeSaidaAlmoco: v.optional(v.string()), nomeSaidaAlmoco: v.optional(v.string()),
nomeRetornoAlmoco: v.optional(v.string()), nomeRetornoAlmoco: v.optional(v.string()),
nomeSaida: v.optional(v.string()), nomeSaida: v.optional(v.string()),
validarLocalizacao: v.optional(v.boolean()),
toleranciaDistanciaMetros: v.optional(v.number()),
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx); const usuario = await getCurrentUserFunction(ctx);
@@ -113,6 +119,13 @@ export const salvarConfiguracao = mutation({
throw new Error('Horário de retorno do almoço deve ser anterior à saída'); throw new Error('Horário de retorno do almoço deve ser anterior à saída');
} }
// Validar tolerância de distância se fornecida
if (args.toleranciaDistanciaMetros !== undefined) {
if (args.toleranciaDistanciaMetros < 0 || args.toleranciaDistanciaMetros > 50000) {
throw new Error('Tolerância de distância deve estar entre 0 e 50000 metros');
}
}
// Desativar configurações antigas // Desativar configurações antigas
const configsAntigas = await ctx.db const configsAntigas = await ctx.db
.query('configuracaoPonto') .query('configuracaoPonto')
@@ -134,6 +147,8 @@ export const salvarConfiguracao = mutation({
nomeSaidaAlmoco: args.nomeSaidaAlmoco || 'Saída 1', nomeSaidaAlmoco: args.nomeSaidaAlmoco || 'Saída 1',
nomeRetornoAlmoco: args.nomeRetornoAlmoco || 'Entrada 2', nomeRetornoAlmoco: args.nomeRetornoAlmoco || 'Entrada 2',
nomeSaida: args.nomeSaida || 'Saída 2', nomeSaida: args.nomeSaida || 'Saída 2',
validarLocalizacao: args.validarLocalizacao ?? true,
toleranciaDistanciaMetros: args.toleranciaDistanciaMetros ?? 100,
ativo: true, ativo: true,
atualizadoPor: usuario._id as Id<'usuarios'>, atualizadoPor: usuario._id as Id<'usuarios'>,
atualizadoEm: Date.now(), atualizadoEm: Date.now(),

View 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 };

View 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 };
},
});

View File

@@ -3,6 +3,225 @@ import { mutation, query } from './_generated/server';
import type { MutationCtx, QueryCtx } from './_generated/server'; import type { MutationCtx, QueryCtx } from './_generated/server';
import { getCurrentUserFunction } from './auth'; import { getCurrentUserFunction } from './auth';
import type { Id } from './_generated/dataModel'; import type { Id } from './_generated/dataModel';
import { validarLocalizacaoGeofencingInternal } from './enderecosMarcacao';
/**
* Calcula distância entre duas coordenadas (fórmula de Haversine)
* Retorna distância em metros
*/
function calcularDistancia(
lat1: number,
lon1: number,
lat2: number,
lon2: number
): number {
const R = 6371000; // Raio da Terra em metros
const dLat = ((lat2 - lat1) * Math.PI) / 180;
const dLon = ((lon2 - lon1) * Math.PI) / 180;
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos((lat1 * Math.PI) / 180) *
Math.cos((lat2 * Math.PI) / 180) *
Math.sin(dLon / 2) *
Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
/**
* Obtém geolocalização aproximada por IP usando serviço externo
*/
async function obterGeoPorIP(ipAddress: string): Promise<{
latitude: number;
longitude: number;
cidade?: string;
estado?: string;
pais?: string;
} | null> {
try {
// Usar ipapi.co (gratuito, sem chave para uso limitado)
const response = await fetch(`https://ipapi.co/${ipAddress}/json/`, {
headers: {
'User-Agent': 'SGSE-App/1.0'
}
});
if (response.ok) {
const data = (await response.json()) as {
latitude?: number;
longitude?: number;
city?: string;
region?: string;
country_name?: string;
error?: boolean;
};
if (!data.error && data.latitude && data.longitude) {
return {
latitude: data.latitude,
longitude: data.longitude,
cidade: data.city,
estado: data.region,
pais: data.country_name
};
}
}
} catch (error) {
console.warn('Erro ao obter geolocalização por IP:', error);
}
return null;
}
/**
* Valida localização contra IP geolocation e histórico
* Retorna informações detalhadas para salvar no registro
*/
async function validarLocalizacao(
ctx: MutationCtx,
funcionarioId: Id<'funcionarios'>,
latitude: number,
longitude: number,
ipAddress?: string,
confiabilidadeGPS?: number
): Promise<{
valida: boolean;
motivo?: string;
scoreConfianca: number; // 0-1
avisos: string[];
distanciaIPvsGPS?: number; // Distância em metros entre IP geolocation e GPS
velocidadeUltimoRegistro?: number; // Velocidade calculada em km/h
distanciaUltimoRegistro?: number; // Distância em metros do último registro
tempoDecorridoHoras?: number; // Tempo em horas desde último registro
}> {
const avisos: string[] = [];
let scoreConfianca = confiabilidadeGPS || 0.5;
let valida = true;
let distanciaIPvsGPS: number | undefined = undefined;
let velocidadeUltimoRegistro: number | undefined = undefined;
let distanciaUltimoRegistro: number | undefined = undefined;
let tempoDecorridoHoras: number | undefined = undefined;
// 1. Validar coordenadas básicas
if (
isNaN(latitude) ||
isNaN(longitude) ||
latitude < -90 ||
latitude > 90 ||
longitude < -180 ||
longitude > 180
) {
return {
valida: false,
motivo: 'Coordenadas inválidas',
scoreConfianca: 0,
avisos: [],
distanciaIPvsGPS,
velocidadeUltimoRegistro,
distanciaUltimoRegistro,
tempoDecorridoHoras
};
}
// 2. Comparar com geolocalização do IP
if (ipAddress) {
const ipGeo = await obterGeoPorIP(ipAddress);
if (ipGeo) {
distanciaIPvsGPS = calcularDistancia(
latitude,
longitude,
ipGeo.latitude,
ipGeo.longitude
);
// Se diferença > 50km, muito suspeito
if (distanciaIPvsGPS > 50000) {
valida = false;
scoreConfianca = Math.min(scoreConfianca, 0.2);
avisos.push(
`Localização GPS (${latitude.toFixed(6)}, ${longitude.toFixed(6)}) está muito distante da localização do IP (${distanciaIPvsGPS.toFixed(0)}m). Possível falsificação.`
);
} else if (distanciaIPvsGPS > 10000) {
// Se diferença entre 10-50km, suspeito mas aceitável (pode ser VPN/mobile)
scoreConfianca *= 0.7;
avisos.push(
`Localização GPS está a ${distanciaIPvsGPS.toFixed(0)}m da localização do IP. Isso pode ser normal se estiver usando VPN ou dados móveis.`
);
} else if (distanciaIPvsGPS < 5000) {
// Se diferença < 5km, aumenta confiança
scoreConfianca = Math.min(scoreConfianca + 0.2, 1);
}
}
}
// 3. Validar histórico de localizações do funcionário
const ultimosRegistros = await ctx.db
.query('registrosPonto')
.withIndex('by_funcionario_data', (q) => q.eq('funcionarioId', funcionarioId))
.order('desc')
.take(5);
if (ultimosRegistros.length > 0) {
// Verificar movimento impossível
for (const registro of ultimosRegistros) {
if (registro.latitude && registro.longitude && registro.timestamp) {
distanciaUltimoRegistro = calcularDistancia(
latitude,
longitude,
registro.latitude,
registro.longitude
);
const tempoDecorrido = Date.now() - registro.timestamp;
tempoDecorridoHoras = tempoDecorrido / (1000 * 60 * 60);
// Calcular velocidade (km/h) se tempo decorrido > 0
if (tempoDecorridoHoras > 0 && tempoDecorridoHoras < 24) {
velocidadeUltimoRegistro = (distanciaUltimoRegistro / 1000) / tempoDecorridoHoras; // km/h
// Se velocidade > 1000 km/h, impossível (mais rápido que avião)
if (velocidadeUltimoRegistro > 1000) {
valida = false;
scoreConfianca = 0;
avisos.push(
`Movimento impossível detectado: ${velocidadeUltimoRegistro.toFixed(0)} km/h. Localização anterior há ${tempoDecorridoHoras.toFixed(1)}h está a ${(distanciaUltimoRegistro / 1000).toFixed(1)}km.`
);
break;
}
// Se velocidade > 200 km/h, suspeito (mas possível em avião)
if (velocidadeUltimoRegistro > 200 && velocidadeUltimoRegistro <= 1000) {
scoreConfianca *= 0.6;
avisos.push(
`Movimento muito rápido: ${velocidadeUltimoRegistro.toFixed(0)} km/h. Pode ser viagem, mas verifique se é legítimo.`
);
}
}
break; // Usar apenas o último registro
}
}
}
// 4. Validar confiabilidade GPS do frontend
if (confiabilidadeGPS !== undefined) {
if (confiabilidadeGPS < 0.3) {
scoreConfianca *= 0.5;
avisos.push(
`Confiabilidade GPS baixa (${(confiabilidadeGPS * 100).toFixed(0)}%). Localização pode não ser precisa.`
);
}
}
return {
valida,
motivo: avisos.length > 0 ? avisos[0] : undefined,
scoreConfianca: Math.max(0, Math.min(1, scoreConfianca)),
avisos,
distanciaIPvsGPS,
velocidadeUltimoRegistro,
distanciaUltimoRegistro,
tempoDecorridoHoras
};
}
/** /**
* Gera URL para upload de imagem do ponto * Gera URL para upload de imagem do ponto
@@ -96,6 +315,13 @@ export const registrarPonto = mutation({
latitude: v.optional(v.number()), latitude: v.optional(v.number()),
longitude: v.optional(v.number()), longitude: v.optional(v.number()),
precisao: v.optional(v.number()), precisao: v.optional(v.number()),
altitude: v.optional(v.union(v.number(), v.null())),
altitudeAccuracy: v.optional(v.union(v.number(), v.null())),
heading: v.optional(v.union(v.number(), v.null())),
speed: v.optional(v.union(v.number(), v.null())),
confiabilidadeGPS: v.optional(v.number()),
suspeitaSpoofing: v.optional(v.boolean()),
motivoSuspeita: v.optional(v.string()),
endereco: v.optional(v.string()), endereco: v.optional(v.string()),
cidade: v.optional(v.string()), cidade: v.optional(v.string()),
estado: v.optional(v.string()), estado: v.optional(v.string()),
@@ -150,13 +376,31 @@ export const registrarPonto = mutation({
.first(); .first();
// Converter timestamp para data/hora com ajuste de GMT // Converter timestamp para data/hora com ajuste de GMT
// O timestamp está em UTC, precisamos aplicar o GMT offset
const gmtOffset = configPonto?.gmtOffset ?? 0; const gmtOffset = configPonto?.gmtOffset ?? 0;
const timestampAjustado = args.timestamp + (gmtOffset * 60 * 60 * 1000);
const dataObj = new Date(timestampAjustado); // Calcular horário ajustado manualmente a partir de UTC
const data = dataObj.toISOString().split('T')[0]!; // YYYY-MM-DD const dataUTC = new Date(args.timestamp);
const hora = dataObj.getUTCHours(); let hora = dataUTC.getUTCHours() + gmtOffset;
const minuto = dataObj.getUTCMinutes(); const minuto = dataUTC.getUTCMinutes();
const segundo = dataObj.getUTCSeconds(); const segundo = dataUTC.getUTCSeconds();
// Ajustar hora se ultrapassar os limites do dia
let diasOffset = 0;
if (hora >= 24) {
hora = hora - 24;
diasOffset = 1;
} else if (hora < 0) {
hora = hora + 24;
diasOffset = -1;
}
// Calcular data ajustada
const dataAjustada = new Date(args.timestamp);
if (diasOffset !== 0) {
dataAjustada.setUTCDate(dataAjustada.getUTCDate() + diasOffset);
}
const data = dataAjustada.toISOString().split('T')[0]!; // YYYY-MM-DD
// Verificar se já existe registro no mesmo minuto // Verificar se já existe registro no mesmo minuto
const funcionarioId = usuario.funcionarioId; // Já verificado acima, não é undefined const funcionarioId = usuario.funcionarioId; // Já verificado acima, não é undefined
@@ -244,6 +488,97 @@ export const registrarPonto = mutation({
const dentroDoPrazo = calcularStatusPonto(hora, minuto, horarioConfigurado, config.toleranciaMinutos); const dentroDoPrazo = calcularStatusPonto(hora, minuto, horarioConfigurado, config.toleranciaMinutos);
// Validar localização se fornecida e salvar informações detalhadas
let validacaoLocalizacao: {
valida: boolean;
motivo?: string;
scoreConfianca: number;
avisos: string[];
distanciaIPvsGPS?: number;
velocidadeUltimoRegistro?: number;
distanciaUltimoRegistro?: number;
tempoDecorridoHoras?: number;
} | null = null;
if (
args.informacoesDispositivo?.latitude &&
args.informacoesDispositivo?.longitude
) {
validacaoLocalizacao = await validarLocalizacao(
ctx,
usuario.funcionarioId,
args.informacoesDispositivo.latitude,
args.informacoesDispositivo.longitude,
args.informacoesDispositivo.ipPublico || args.informacoesDispositivo.ipAddress,
args.informacoesDispositivo.confiabilidadeGPS
);
// Sempre registrar, mesmo com baixa confiabilidade
// Mas salvar todas as informações detalhadas para análise posterior
const suspeitaFrontend = args.informacoesDispositivo.suspeitaSpoofing;
const suspeitaBackend = !validacaoLocalizacao.valida;
const baixaConfianca = validacaoLocalizacao.scoreConfianca < 0.5;
if (suspeitaFrontend || suspeitaBackend || baixaConfianca) {
console.warn('⚠️ LOCALIZAÇÃO COM BAIXA CONFIABILIDADE DETECTADA (registrando normalmente):', {
funcionarioId: usuario.funcionarioId,
latitude: args.informacoesDispositivo.latitude,
longitude: args.informacoesDispositivo.longitude,
confiabilidadeGPSFrontend: args.informacoesDispositivo.confiabilidadeGPS,
scoreConfiancaBackend: validacaoLocalizacao.scoreConfianca,
suspeitaFrontend: suspeitaFrontend ? args.informacoesDispositivo.motivoSuspeita : null,
suspeitaBackend: suspeitaBackend ? validacaoLocalizacao.motivo : null,
avisos: validacaoLocalizacao.avisos
});
}
}
// Validar geofencing (localização permitida) se habilitado
let validacaoGeofencing: {
dentroRaio: boolean;
enderecoMaisProximo?: Id<'enderecosMarcacao'>;
distanciaMetros?: number;
raioUsado?: number;
enderecoEncontrado?: string;
avisos: string[];
} | null = null;
if (
configPonto?.validarLocalizacao !== false &&
args.informacoesDispositivo?.latitude &&
args.informacoesDispositivo?.longitude
) {
const geofencing = await validarLocalizacaoGeofencingInternal(
ctx,
usuario.funcionarioId,
args.informacoesDispositivo.latitude,
args.informacoesDispositivo.longitude,
configPonto?.toleranciaDistanciaMetros ?? 100
);
validacaoGeofencing = geofencing;
// Adicionar avisos de geofencing aos avisos de validação
if (geofencing.avisos.length > 0) {
if (!validacaoLocalizacao) {
validacaoLocalizacao = {
valida: true,
scoreConfianca: 1,
avisos: [],
};
}
validacaoLocalizacao.avisos.push(...geofencing.avisos);
// Reduzir score de confiança se estiver fora do raio
if (!geofencing.dentroRaio) {
validacaoLocalizacao.scoreConfianca = Math.min(
validacaoLocalizacao.scoreConfianca,
0.7
);
}
}
}
// Criar registro // Criar registro
const registroId = await ctx.db.insert('registrosPonto', { const registroId = await ctx.db.insert('registrosPonto', {
funcionarioId: usuario.funcionarioId, funcionarioId: usuario.funcionarioId,
@@ -272,6 +607,22 @@ export const registrarPonto = mutation({
latitude: args.informacoesDispositivo?.latitude, latitude: args.informacoesDispositivo?.latitude,
longitude: args.informacoesDispositivo?.longitude, longitude: args.informacoesDispositivo?.longitude,
precisao: args.informacoesDispositivo?.precisao, precisao: args.informacoesDispositivo?.precisao,
altitude: args.informacoesDispositivo?.altitude,
altitudeAccuracy: args.informacoesDispositivo?.altitudeAccuracy,
heading: args.informacoesDispositivo?.heading,
speed: args.informacoesDispositivo?.speed,
confiabilidadeGPS: args.informacoesDispositivo?.confiabilidadeGPS,
scoreConfiancaBackend: validacaoLocalizacao?.scoreConfianca,
suspeitaSpoofing: args.informacoesDispositivo?.suspeitaSpoofing || (validacaoLocalizacao ? validacaoLocalizacao.scoreConfianca < 0.5 || !validacaoLocalizacao.valida : undefined),
motivoSuspeita: args.informacoesDispositivo?.motivoSuspeita || validacaoLocalizacao?.motivo || (validacaoLocalizacao && validacaoLocalizacao.avisos.length > 0 ? validacaoLocalizacao.avisos.join('; ') : undefined),
// Informações detalhadas de validação (sempre salvar quando houver validação)
avisosValidacao: validacaoLocalizacao && validacaoLocalizacao.avisos.length > 0 ? validacaoLocalizacao.avisos : undefined,
// Informações de Geofencing
enderecoMarcacaoEsperado: validacaoGeofencing?.enderecoMaisProximo,
distanciaEnderecoEsperado: validacaoGeofencing?.distanciaMetros,
dentroRaioPermitido: validacaoGeofencing?.dentroRaio,
enderecoMarcacaoUsado: validacaoGeofencing?.enderecoMaisProximo,
raioToleranciaUsado: validacaoGeofencing?.raioUsado,
endereco: args.informacoesDispositivo?.endereco, endereco: args.informacoesDispositivo?.endereco,
cidade: args.informacoesDispositivo?.cidade, cidade: args.informacoesDispositivo?.cidade,
estado: args.informacoesDispositivo?.estado, estado: args.informacoesDispositivo?.estado,
@@ -384,15 +735,32 @@ export const listarRegistrosPeriodo = query({
const dataFim = new Date(args.dataFim); const dataFim = new Date(args.dataFim);
dataFim.setHours(23, 59, 59, 999); dataFim.setHours(23, 59, 59, 999);
const registros = await ctx.db let registrosFiltrados;
.query('registrosPonto')
.withIndex('by_data', (q) => q.gte('data', args.dataInicio).lte('data', args.dataFim)) // Se funcionário foi especificado, usar índice por funcionário e data (mais eficiente)
.collect();
// Filtrar por funcionário se especificado
let registrosFiltrados = registros;
if (args.funcionarioId) { 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 // 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 * Obtém opções de motivos de atestados/declarações
*/ */

View File

@@ -1416,6 +1416,25 @@ export default defineSchema({
latitude: v.optional(v.number()), latitude: v.optional(v.number()),
longitude: v.optional(v.number()), longitude: v.optional(v.number()),
precisao: v.optional(v.number()), precisao: v.optional(v.number()),
altitude: v.optional(v.union(v.number(), v.null())),
altitudeAccuracy: v.optional(v.union(v.number(), v.null())),
heading: v.optional(v.union(v.number(), v.null())),
speed: v.optional(v.union(v.number(), v.null())),
confiabilidadeGPS: v.optional(v.number()), // 0-1 (frontend)
scoreConfiancaBackend: v.optional(v.number()), // 0-1 (backend)
suspeitaSpoofing: v.optional(v.boolean()),
motivoSuspeita: v.optional(v.string()),
avisosValidacao: v.optional(v.array(v.string())), // Array de avisos detalhados da validação
distanciaIPvsGPS: v.optional(v.number()), // Distância em metros entre IP geolocation e GPS
velocidadeUltimoRegistro: v.optional(v.number()), // Velocidade em km/h do último registro
distanciaUltimoRegistro: v.optional(v.number()), // Distância em metros do último registro
tempoDecorridoHoras: v.optional(v.number()), // Tempo em horas desde o último registro
// Informações de Geofencing
enderecoMarcacaoEsperado: v.optional(v.id("enderecosMarcacao")), // Endereço mais próximo esperado
distanciaEnderecoEsperado: v.optional(v.number()), // Distância em metros do endereço esperado
dentroRaioPermitido: v.optional(v.boolean()), // Se está dentro do raio permitido
enderecoMarcacaoUsado: v.optional(v.id("enderecosMarcacao")), // Qual endereço foi usado na validação
raioToleranciaUsado: v.optional(v.number()), // Raio usado na validação em metros
endereco: v.optional(v.string()), endereco: v.optional(v.string()),
cidade: v.optional(v.string()), cidade: v.optional(v.string()),
estado: v.optional(v.string()), estado: v.optional(v.string()),
@@ -1450,6 +1469,60 @@ export default defineSchema({
.index("by_dentro_prazo", ["dentroDoPrazo", "data"]) .index("by_dentro_prazo", ["dentroDoPrazo", "data"])
.index("by_funcionario_timestamp", ["funcionarioId", "timestamp"]), .index("by_funcionario_timestamp", ["funcionarioId", "timestamp"]),
// Endereços de Marcação - Locais permitidos para registro de ponto
enderecosMarcacao: defineTable({
nome: v.string(), // Ex: "Sede Principal", "Home Office João Silva", "Cliente ABC"
descricao: v.optional(v.string()), // Descrição opcional
// Coordenadas (obrigatórias)
latitude: v.number(),
longitude: v.number(),
// Endereço físico (para exibição)
endereco: v.string(), // Ex: "Rua Exemplo, 123"
bairro: v.optional(v.string()), // Bairro do endereço
cep: v.optional(v.string()),
cidade: v.string(),
estado: v.string(),
pais: v.optional(v.string()), // Padrão: "Brasil"
// Configurações
raioMetros: v.number(), // Raio de tolerância em metros (ex: 100m, 500m, 1000m)
ativo: v.boolean(),
// Tipos de uso
tipo: v.union(
v.literal("sede"), // Sede principal (para todos)
v.literal("home_office"), // Home office específico
v.literal("deslocamento"), // Deslocamento temporário
v.literal("cliente") // Local de cliente
),
// Metadados
criadoPor: v.id("usuarios"),
criadoEm: v.number(),
atualizadoPor: v.optional(v.id("usuarios")),
atualizadoEm: v.optional(v.number()),
})
.index("by_ativo", ["ativo"])
.index("by_tipo", ["tipo"])
.index("by_cidade", ["cidade"]),
// Associação Funcionário ↔ Endereço de Marcação
funcionarioEnderecosMarcacao: defineTable({
funcionarioId: v.id("funcionarios"),
enderecoMarcacaoId: v.id("enderecosMarcacao"),
// Configurações específicas do funcionário
raioMetrosPersonalizado: v.optional(v.number()), // Pode ter raio diferente do padrão
// Período de validade (para deslocamentos temporários)
dataInicio: v.optional(v.string()), // YYYY-MM-DD
dataFim: v.optional(v.string()), // YYYY-MM-DD
// Status
ativo: v.boolean(),
// Metadados
criadoPor: v.id("usuarios"),
criadoEm: v.number(),
})
.index("by_funcionario", ["funcionarioId"])
.index("by_endereco", ["enderecoMarcacaoId"])
.index("by_funcionario_ativo", ["funcionarioId", "ativo"])
.index("by_endereco_ativo", ["enderecoMarcacaoId", "ativo"]),
configuracaoPonto: defineTable({ configuracaoPonto: defineTable({
horarioEntrada: v.string(), // HH:mm horarioEntrada: v.string(), // HH:mm
horarioSaidaAlmoco: v.string(), // HH:mm horarioSaidaAlmoco: v.string(), // HH:mm
@@ -1463,6 +1536,9 @@ export default defineSchema({
nomeSaida: v.optional(v.string()), // Padrão: "Saída 2" nomeSaida: v.optional(v.string()), // Padrão: "Saída 2"
// Ajuste de fuso horário (GMT offset em horas) // Ajuste de fuso horário (GMT offset em horas)
gmtOffset: v.optional(v.number()), // Padrão: 0 (UTC) gmtOffset: v.optional(v.number()), // Padrão: 0 (UTC)
// Configurações de geofencing
validarLocalizacao: v.optional(v.boolean()), // Habilitar/desabilitar validação de localização
toleranciaDistanciaMetros: v.optional(v.number()), // Raio padrão global em metros
ativo: v.boolean(), ativo: v.boolean(),
atualizadoPor: v.id("usuarios"), atualizadoPor: v.id("usuarios"),
atualizadoEm: v.number(), atualizadoEm: v.number(),