+
@@ -99,7 +99,7 @@
Gerencie os funcionários da secretaria
-
+
-
+
-
-
-
-
-
-
-
-
- Nome
- CPF
- Matrícula
- Tipo
- Cidade
- UF
- Ações
-
-
-
- {#each filtered as f}
-
- {f.nome}
- {f.cpf}
- {f.matricula}
- {f.simboloTipo}
- {f.cidade}
- {f.uf}
-
-
-
toggleMenu(f._id)}
- >
+
+
+
+
+
+
+
+
+
+
+
+ Nome
+ CPF
+ Matrícula
+ Tipo
+ Cidade
+ UF
+ Ações
+
+
+
+ {#if filtered.length === 0}
+
+
+
-
-
-
- Ver Detalhes
-
-
- Editar
-
-
- Ver Documentos
+
+
+
Nenhum funcionário encontrado
+
+ {#if filtroNome || filtroCPF || filtroMatricula || filtroTipo}
+ Tente ajustar os filtros ou
+ {/if}
+
+ cadastre um novo funcionário
+
+
+
+
+
+
+ {:else}
+ {#each filtered as f}
+
+ {f.nome}
+ {f.cpf}
+ {f.matricula}
+
+
+ {f.simboloTipo === 'cargo_comissionado' ? 'Cargo Comissionado' :
+ f.simboloTipo === 'funcao_gratificada' ? 'Função Gratificada' :
+ f.simboloTipo || '-'}
+
+
+ {f.cidade || '-'}
+ {f.uf || '-'}
+
+
+ toggleMenu(f._id)}
>
-
-
- openPrintModal(f._id)}>Imprimir Ficha
-
-
-
-
-
- {/each}
-
-
+
+
+
+
+
+
+
+ Ver Detalhes
+
+
+
+
+ Editar
+
+
+
+
+ Ver Documentos
+
+
+
+ openPrintModal(f._id)} class="hover:bg-primary/10">
+ Imprimir Ficha
+
+
+
+
+
+
+ {/each}
+ {/if}
+
+
+
-
-
-
- Exibindo {filtered.length} de {list.length} funcionário(s)
+
+
+ Exibindo {filtered.length} de {list.length} funcionário(s)
+
diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/[funcionarioId]/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/[funcionarioId]/+page.svelte
index 302548d..b2d838e 100644
--- a/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/[funcionarioId]/+page.svelte
+++ b/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/[funcionarioId]/+page.svelte
@@ -15,6 +15,7 @@
APOSENTADO_OPTIONS,
} from "$lib/utils/constants";
import PrintModal from "$lib/components/PrintModal.svelte";
+ import { MapPin } from "lucide-svelte";
const client = useConvexClient();
@@ -203,6 +204,16 @@
Imprimir Ficha
+
+ goto(
+ resolve(`/recursos-humanos/funcionarios/${funcionarioId}/enderecos-marcacao`),
+ )}
+ >
+
+ Endereços de Marcação
+
diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/[funcionarioId]/enderecos-marcacao/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/[funcionarioId]/enderecos-marcacao/+page.svelte
new file mode 100644
index 0000000..955a565
--- /dev/null
+++ b/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/[funcionarioId]/enderecos-marcacao/+page.svelte
@@ -0,0 +1,497 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Endereços de Marcação
+
+ {#if funcionario}
+ Gerenciar locais permitidos para registro de ponto de {funcionario.nome}
+ {:else}
+ Carregando...
+ {/if}
+
+
+
+ {#if enderecosParaAssociar.length > 0}
+
{
+ limparFormulario();
+ mostrarModalAssociacao = true;
+ }}
+ disabled={mostrarModalAssociacao}
+ >
+
+ Associar Endereço
+
+ {/if}
+
+
+
+ {#if mostrarModalAssociacao}
+
+
+
+
+ {editandoAssociacao ? 'Editar Associação' : 'Associar Endereço'}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Período de Validade (opcional)
+
+
+
+
+
+
+ Cancelar
+
+
+ {#if processando}
+
+ {:else}
+ Salvar
+ {/if}
+
+
+
+
+ {/if}
+
+
+
+
+
+ {#if associacoes.length === 0}
+
+
+
+
+
+ Este funcionário não possui endereços específicos associados. O sistema usará
+ automaticamente os endereços tipo "Sede Principal" configurados globalmente.
+
+
+ {/if}
+
+
+
+ {#if associacoesFiltradas.length === 0}
+
+
+
+
+ {termoBusca
+ ? 'Nenhuma associação encontrada'
+ : 'Nenhum endereço associado'}
+
+
+
+ {:else}
+ {#each associacoesFiltradas as associacao (associacao._id)}
+ {@const raioUsado = associacao.raioMetros}
+
+
+
+
+
+
{associacao.endereco.nome}
+
+ {associacao.ativo ? 'Ativa' : 'Inativa'}
+
+
+ {tiposLabel[associacao.endereco.tipo] || associacao.endereco.tipo}
+
+
+
+
+ Endereço: {associacao.endereco.endereco}
+
+
+ Cidade: {associacao.endereco.cidade}/
+ {associacao.endereco.estado}
+
+
+ Raio Permitido:
+ {#if raioUsado >= 1000}
+ {@const raioKm = (raioUsado / 1000).toFixed(2)}
+ {raioKm} km
+ {:else}
+ {raioUsado} metros
+ {/if}
+ {#if associacao.raioMetrosPersonalizado !== null &&
+ associacao.raioMetrosPersonalizado !== undefined}
+
+ Personalizado
+
+ {:else}
+
+ Padrão ({associacao.endereco.raioMetros}m)
+
+ {/if}
+
+ {#if associacao.dataInicio || associacao.dataFim}
+
+ Período:
+ {#if associacao.dataInicio}
+ {@const dataInicioFormatada = new Date(associacao.dataInicio + 'T00:00:00').toLocaleDateString('pt-BR')}
+ {dataInicioFormatada}
+ {:else}
+ ...
+ {/if}
+ até
+ {#if associacao.dataFim}
+ {@const dataFimFormatada = new Date(associacao.dataFim + 'T00:00:00').toLocaleDateString('pt-BR')}
+ {dataFimFormatada}
+ {:else}
+ ...
+ {/if}
+
+ {/if}
+
+
+
+
+ abrirFormularioEdicao(associacao)}
+ >
+
+ Editar
+
+ removerAssociacao(associacao._id)}
+ >
+
+ Remover
+
+
+
+
+
+ {/each}
+ {/if}
+
+
+
diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/registro-pontos/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/registro-pontos/+page.svelte
index 754a497..04c223e 100644
--- a/apps/web/src/routes/(dashboard)/recursos-humanos/registro-pontos/+page.svelte
+++ b/apps/web/src/routes/(dashboard)/recursos-humanos/registro-pontos/+page.svelte
@@ -74,118 +74,126 @@
};
});
- // Inicializar gráfico
- $effect(() => {
+ // Função para criar/atualizar o gráfico
+ function criarGrafico() {
if (!chartCanvas || !estatisticas || !chartData) {
return;
}
+ const ctx = chartCanvas.getContext('2d');
+ if (!ctx) {
+ return;
+ }
+
// Destruir gráfico anterior se existir
if (chartInstance) {
chartInstance.destroy();
chartInstance = null;
}
- // Aguardar um pouco para garantir que o canvas está renderizado
- const timeoutId = setTimeout(() => {
- if (!chartCanvas || !estatisticas || !chartData) {
- return;
- }
-
- const ctx = chartCanvas.getContext('2d');
- if (!ctx) {
- return;
- }
-
- try {
- chartInstance = new Chart(ctx, {
- type: 'bar',
- data: chartData,
- options: {
- responsive: true,
- maintainAspectRatio: false,
- plugins: {
- legend: {
- display: true,
- position: 'top',
- labels: {
- color: 'hsl(var(--bc))',
- font: {
- size: 12,
- family: "'Inter', sans-serif",
- },
- usePointStyle: true,
- padding: 15,
- }
- },
- tooltip: {
- backgroundColor: 'rgba(0, 0, 0, 0.85)',
- titleColor: '#fff',
- bodyColor: '#fff',
- borderColor: 'hsl(var(--p))',
- borderWidth: 1,
- padding: 12,
- callbacks: {
- label: function(context) {
- const label = context.dataset.label || '';
- const value = context.parsed.y;
- const total = estatisticas.totalRegistros;
- const percentage = total > 0 ? ((value / total) * 100).toFixed(1) : '0.0';
- return `${label}: ${value} (${percentage}%)`;
- }
- }
+ try {
+ chartInstance = new Chart(ctx, {
+ type: 'bar',
+ data: chartData,
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: {
+ legend: {
+ display: true,
+ position: 'top',
+ labels: {
+ color: 'hsl(var(--bc))',
+ font: {
+ size: 12,
+ family: "'Inter', sans-serif",
+ },
+ usePointStyle: true,
+ padding: 15,
}
},
- scales: {
- x: {
- stacked: true,
- grid: {
- display: false,
- },
- ticks: {
- color: 'hsl(var(--bc))',
- font: {
- size: 12,
- }
- }
- },
- y: {
- stacked: true,
- beginAtZero: true,
- grid: {
- color: 'rgba(0, 0, 0, 0.05)',
- },
- ticks: {
- color: 'hsl(var(--bc))',
- font: {
- size: 11,
- },
- stepSize: 1,
+ tooltip: {
+ backgroundColor: 'rgba(0, 0, 0, 0.85)',
+ titleColor: '#fff',
+ bodyColor: '#fff',
+ borderColor: 'hsl(var(--p))',
+ borderWidth: 1,
+ padding: 12,
+ callbacks: {
+ label: function(context) {
+ const label = context.dataset.label || '';
+ const value = context.parsed.y;
+ const total = estatisticas.totalRegistros;
+ const percentage = total > 0 ? ((value / total) * 100).toFixed(1) : '0.0';
+ return `${label}: ${value} (${percentage}%)`;
}
}
- },
- animation: {
- duration: 1000,
- easing: 'easeInOutQuart'
- },
- interaction: {
- mode: 'index',
- intersect: false,
}
+ },
+ scales: {
+ x: {
+ stacked: true,
+ grid: {
+ display: false,
+ },
+ ticks: {
+ color: 'hsl(var(--bc))',
+ font: {
+ size: 12,
+ }
+ }
+ },
+ y: {
+ stacked: true,
+ beginAtZero: true,
+ grid: {
+ color: 'rgba(0, 0, 0, 0.05)',
+ },
+ ticks: {
+ color: 'hsl(var(--bc))',
+ font: {
+ size: 11,
+ },
+ stepSize: 1,
+ }
+ }
+ },
+ animation: {
+ duration: 1000,
+ easing: 'easeInOutQuart'
+ },
+ interaction: {
+ mode: 'index',
+ intersect: false,
}
- });
- } catch (error) {
- console.error('Erro ao criar gráfico:', error);
- }
- }, 100);
+ }
+ });
+ } catch (error) {
+ console.error('Erro ao criar gráfico:', error);
+ }
+ }
- return () => {
- clearTimeout(timeoutId);
- if (chartInstance) {
- chartInstance.destroy();
- chartInstance = null;
- }
- };
+ // Inicializar gráfico quando canvas e dados estiverem disponíveis
+ $effect(() => {
+ if (chartCanvas && estatisticas && chartData) {
+ // Aguardar um pouco para garantir que o canvas está renderizado
+ const timeoutId = setTimeout(() => {
+ criarGrafico();
+ }, 100);
+
+ return () => {
+ clearTimeout(timeoutId);
+ };
+ }
+ });
+
+ // Também tentar criar quando o canvas for montado
+ onMount(() => {
+ if (chartCanvas && estatisticas && chartData) {
+ setTimeout(() => {
+ criarGrafico();
+ }, 200);
+ }
});
onDestroy(() => {
@@ -1016,6 +1024,357 @@
yPosition += 5;
}
+ // Validação de GPS e Anti-Spoofing
+ if (registro.latitude && registro.longitude) {
+ // Verificar se precisa de nova página
+ if (yPosition > 200) {
+ doc.addPage();
+ yPosition = 20;
+ }
+
+ doc.setFont('helvetica', 'bold');
+ doc.setFontSize(12);
+ doc.text('VALIDAÇÃO DE LOCALIZAÇÃO GPS', 15, yPosition);
+ doc.setFont('helvetica', 'normal');
+ doc.setFontSize(10);
+ yPosition += 10;
+
+ // Informações detalhadas do GPS
+ doc.setFont('helvetica', 'bold');
+ doc.text('Dados do GPS:', 15, yPosition);
+ doc.setFont('helvetica', 'normal');
+ yPosition += 6;
+
+ if (registro.precisao !== null && registro.precisao !== undefined) {
+ doc.text(` Precisão: ${registro.precisao.toFixed(2)} metros`, 20, yPosition);
+ yPosition += 6;
+ }
+
+ if (registro.altitude !== null && registro.altitude !== undefined) {
+ doc.text(` Altitude: ${registro.altitude.toFixed(2)} metros`, 20, yPosition);
+ yPosition += 6;
+ }
+
+ if (registro.altitudeAccuracy !== null && registro.altitudeAccuracy !== undefined) {
+ doc.text(` Precisão da Altitude: ${registro.altitudeAccuracy.toFixed(2)} metros`, 20, yPosition);
+ yPosition += 6;
+ }
+
+ if (registro.heading !== null && registro.heading !== undefined) {
+ doc.text(` Direção (Heading): ${registro.heading.toFixed(2)}°`, 20, yPosition);
+ yPosition += 6;
+ }
+
+ if (registro.speed !== null && registro.speed !== undefined) {
+ doc.text(` Velocidade: ${(registro.speed * 3.6).toFixed(2)} km/h`, 20, yPosition);
+ yPosition += 6;
+ }
+
+ yPosition += 3;
+
+ // Confiabilidade e Scores
+ doc.setFont('helvetica', 'bold');
+ doc.text('Confiabilidade:', 15, yPosition);
+ doc.setFont('helvetica', 'normal');
+ yPosition += 6;
+
+ if (registro.confiabilidadeGPS !== null && registro.confiabilidadeGPS !== undefined) {
+ const confiabilidadePercent = (registro.confiabilidadeGPS * 100).toFixed(1);
+ const confiabilidadeCor = registro.confiabilidadeGPS >= 0.7 ? [0, 128, 0] : registro.confiabilidadeGPS >= 0.4 ? [255, 165, 0] : [255, 0, 0];
+ doc.setTextColor(confiabilidadeCor[0], confiabilidadeCor[1], confiabilidadeCor[2]);
+ doc.text(` Confiabilidade GPS (Frontend): ${confiabilidadePercent}%`, 20, yPosition);
+ doc.setTextColor(0, 0, 0);
+ yPosition += 6;
+ }
+
+ if (registro.scoreConfiancaBackend !== null && registro.scoreConfiancaBackend !== undefined) {
+ const scorePercent = (registro.scoreConfiancaBackend * 100).toFixed(1);
+ const scoreCor = registro.scoreConfiancaBackend >= 0.7 ? [0, 128, 0] : registro.scoreConfiancaBackend >= 0.4 ? [255, 165, 0] : [255, 0, 0];
+ doc.setTextColor(scoreCor[0], scoreCor[1], scoreCor[2]);
+ doc.text(` Score de Confiança (Backend): ${scorePercent}%`, 20, yPosition);
+ doc.setTextColor(0, 0, 0);
+ yPosition += 6;
+ }
+
+ yPosition += 3;
+
+ // Status de Validação
+ if (registro.suspeitaSpoofing !== null && registro.suspeitaSpoofing !== undefined) {
+ doc.setFont('helvetica', 'bold');
+ doc.text('Status de Validação:', 15, yPosition);
+ doc.setFont('helvetica', 'normal');
+ yPosition += 6;
+
+ if (registro.suspeitaSpoofing) {
+ doc.setTextColor(255, 0, 0);
+ doc.setFont('helvetica', 'bold');
+ doc.text(' ⚠️ MARCAÇÃO SUSPEITA DETECTADA', 20, yPosition);
+ doc.setFont('helvetica', 'normal');
+ doc.setTextColor(0, 0, 0);
+ yPosition += 6;
+ } else {
+ doc.setTextColor(0, 128, 0);
+ doc.setFont('helvetica', 'bold');
+ doc.text(' ✓ Localização validada com sucesso', 20, yPosition);
+ doc.setFont('helvetica', 'normal');
+ doc.setTextColor(0, 0, 0);
+ yPosition += 6;
+ }
+
+ if (registro.motivoSuspeita) {
+ doc.setTextColor(255, 0, 0);
+ const motivoLines = doc.splitTextToSize(` Motivo: ${registro.motivoSuspeita}`, 170);
+ doc.text(motivoLines, 20, yPosition);
+ yPosition += motivoLines.length * 5;
+ doc.setTextColor(0, 0, 0);
+ }
+
+ yPosition += 3;
+ }
+
+ // Avisos de Validação
+ if (registro.avisosValidacao && registro.avisosValidacao.length > 0) {
+ doc.setFont('helvetica', 'bold');
+ doc.text('Avisos de Validação:', 15, yPosition);
+ doc.setFont('helvetica', 'normal');
+ yPosition += 6;
+
+ registro.avisosValidacao.forEach((aviso: string) => {
+ const avisoLines = doc.splitTextToSize(` • ${aviso}`, 170);
+ doc.text(avisoLines, 20, yPosition);
+ yPosition += avisoLines.length * 5;
+ });
+
+ yPosition += 3;
+ }
+
+ // Análise de Propriedades GPS
+ doc.setFont('helvetica', 'bold');
+ doc.text('Análise de Propriedades GPS:', 15, yPosition);
+ doc.setFont('helvetica', 'normal');
+ yPosition += 6;
+
+ let propriedadesGPS = 0;
+ let propriedadesTotais = 5;
+
+ if (registro.altitude !== null && registro.altitude !== undefined && registro.altitude !== 0) {
+ doc.text(' ✓ Altitude disponível', 20, yPosition);
+ propriedadesGPS++;
+ } else {
+ doc.text(' ✗ Altitude não disponível', 20, yPosition);
+ }
+ yPosition += 5;
+
+ if (registro.altitudeAccuracy !== null && registro.altitudeAccuracy !== undefined && registro.altitudeAccuracy > 0) {
+ doc.text(' ✓ Precisão de altitude disponível', 20, yPosition);
+ propriedadesGPS++;
+ } else {
+ doc.text(' ✗ Precisão de altitude não disponível', 20, yPosition);
+ }
+ yPosition += 5;
+
+ if (registro.heading !== null && registro.heading !== undefined && !isNaN(registro.heading)) {
+ doc.text(' ✓ Direção (heading) disponível', 20, yPosition);
+ propriedadesGPS++;
+ } else {
+ doc.text(' ✗ Direção (heading) não disponível', 20, yPosition);
+ }
+ yPosition += 5;
+
+ if (registro.speed !== null && registro.speed !== undefined && !isNaN(registro.speed)) {
+ doc.text(' ✓ Velocidade disponível', 20, yPosition);
+ propriedadesGPS++;
+ } else {
+ doc.text(' ✗ Velocidade não disponível', 20, yPosition);
+ }
+ yPosition += 5;
+
+ if (registro.precisao !== null && registro.precisao !== undefined && registro.precisao < 20) {
+ doc.text(' ✓ Alta precisão GPS (< 20m)', 20, yPosition);
+ propriedadesGPS++;
+ } else if (registro.precisao !== null && registro.precisao !== undefined && registro.precisao >= 20 && registro.precisao < 100) {
+ doc.text(' ⚠ Precisão média GPS (20-100m)', 20, yPosition);
+ propriedadesGPS += 0.5;
+ } else {
+ doc.text(' ✗ Baixa precisão GPS (> 100m)', 20, yPosition);
+ }
+ yPosition += 5;
+
+ // Indicador de qualidade GPS
+ const qualidadeGPS = (propriedadesGPS / propriedadesTotais) * 100;
+ const qualidadeTexto = qualidadeGPS >= 80 ? 'Alta qualidade (GPS real)' : qualidadeGPS >= 50 ? 'Qualidade média' : 'Baixa qualidade (possível spoofing)';
+ const qualidadeCor = qualidadeGPS >= 80 ? [0, 128, 0] : qualidadeGPS >= 50 ? [255, 165, 0] : [255, 0, 0];
+
+ doc.setFont('helvetica', 'bold');
+ doc.setTextColor(qualidadeCor[0], qualidadeCor[1], qualidadeCor[2]);
+ doc.text(`Qualidade GPS: ${qualidadeTexto} (${qualidadeGPS.toFixed(0)}% das propriedades)`, 20, yPosition);
+ doc.setFont('helvetica', 'normal');
+ doc.setTextColor(0, 0, 0);
+ yPosition += 8;
+ }
+
+ // Validação de Geofencing (Localização Permitida)
+ if (registro.latitude && registro.longitude) {
+ // Verificar se precisa de nova página
+ if (yPosition > 200) {
+ doc.addPage();
+ yPosition = 20;
+ }
+
+ doc.setFont('helvetica', 'bold');
+ doc.setFontSize(12);
+ doc.text('VALIDAÇÃO DE LOCALIZAÇÃO PERMITIDA', 15, yPosition);
+ doc.setFont('helvetica', 'normal');
+ doc.setFontSize(10);
+ yPosition += 10;
+
+ if (registro.enderecoMarcacaoEsperado || registro.dentroRaioPermitido !== undefined) {
+ // Buscar dados do endereço esperado se houver ID
+ let enderecoEsperadoNome = 'Não configurado';
+ let enderecoEsperadoEndereco = 'Não configurado';
+ let enderecoEsperadoLatitude: number | null = null;
+ let enderecoEsperadoLongitude: number | null = null;
+
+ if (registro.enderecoMarcacaoEsperado) {
+ try {
+ const enderecoEsperado = await client.query(
+ api.enderecosMarcacao.obterEndereco,
+ { enderecoId: registro.enderecoMarcacaoEsperado }
+ );
+ if (enderecoEsperado) {
+ enderecoEsperadoNome = enderecoEsperado.nome;
+ enderecoEsperadoEndereco = `${enderecoEsperado.endereco}, ${enderecoEsperado.cidade}/${enderecoEsperado.estado}`;
+ enderecoEsperadoLatitude = enderecoEsperado.latitude;
+ enderecoEsperadoLongitude = enderecoEsperado.longitude;
+ }
+ } catch (error) {
+ console.warn('Erro ao buscar endereço esperado:', error);
+ }
+ }
+
+ doc.setFont('helvetica', 'bold');
+ doc.text('Endereço Esperado:', 15, yPosition);
+ doc.setFont('helvetica', 'normal');
+ yPosition += 6;
+
+ doc.text(` Nome: ${enderecoEsperadoNome}`, 20, yPosition);
+ yPosition += 6;
+
+ const enderecoLines = doc.splitTextToSize(` Endereço: ${enderecoEsperadoEndereco}`, 170);
+ doc.text(enderecoLines, 20, yPosition);
+ yPosition += enderecoLines.length * 5 + 3;
+
+ if (enderecoEsperadoLatitude !== null && enderecoEsperadoLongitude !== null) {
+ doc.text(` Coordenadas: ${enderecoEsperadoLatitude.toFixed(6)}, ${enderecoEsperadoLongitude.toFixed(6)}`, 20, yPosition);
+ yPosition += 6;
+ }
+
+ yPosition += 3;
+
+ doc.setFont('helvetica', 'bold');
+ doc.text('Localização do Registro:', 15, yPosition);
+ doc.setFont('helvetica', 'normal');
+ yPosition += 6;
+
+ doc.text(` Coordenadas: ${registro.latitude.toFixed(6)}, ${registro.longitude.toFixed(6)}`, 20, yPosition);
+ yPosition += 6;
+
+ if (registro.distanciaEnderecoEsperado !== null && registro.distanciaEnderecoEsperado !== undefined) {
+ const distanciaKm = (registro.distanciaEnderecoEsperado / 1000).toFixed(2);
+ const distanciaMetros = registro.distanciaEnderecoEsperado.toFixed(0);
+
+ if (registro.distanciaEnderecoEsperado >= 1000) {
+ doc.text(` Distância: ${distanciaKm} km (${distanciaMetros} metros)`, 20, yPosition);
+ } else {
+ doc.text(` Distância: ${distanciaMetros} metros`, 20, yPosition);
+ }
+ yPosition += 6;
+ }
+
+ yPosition += 3;
+
+ doc.setFont('helvetica', 'bold');
+ doc.text('Raio Permitido:', 15, yPosition);
+ doc.setFont('helvetica', 'normal');
+ yPosition += 6;
+
+ if (registro.raioToleranciaUsado !== null && registro.raioToleranciaUsado !== undefined) {
+ const raioKm = (registro.raioToleranciaUsado / 1000).toFixed(2);
+ const raioMetros = registro.raioToleranciaUsado.toFixed(0);
+
+ if (registro.raioToleranciaUsado >= 1000) {
+ doc.text(` ${raioKm} km (${raioMetros} metros)`, 20, yPosition);
+ } else {
+ doc.text(` ${raioMetros} metros`, 20, yPosition);
+ }
+ yPosition += 6;
+ } else {
+ doc.text(' Não configurado', 20, yPosition);
+ yPosition += 6;
+ }
+
+ yPosition += 3;
+
+ // Status da validação
+ doc.setFont('helvetica', 'bold');
+ doc.text('Status:', 15, yPosition);
+ doc.setFont('helvetica', 'normal');
+ yPosition += 6;
+
+ if (registro.dentroRaioPermitido === true) {
+ doc.setTextColor(0, 128, 0);
+ doc.setFont('helvetica', 'bold');
+ doc.text(' ✓ DENTRO DO RAIO PERMITIDO', 20, yPosition);
+ doc.setFont('helvetica', 'normal');
+ doc.setTextColor(0, 0, 0);
+ } else if (registro.dentroRaioPermitido === false) {
+ doc.setTextColor(255, 0, 0);
+ doc.setFont('helvetica', 'bold');
+ doc.text(' ⚠️ FORA DO RAIO PERMITIDO', 20, yPosition);
+ doc.setFont('helvetica', 'normal');
+ doc.setTextColor(0, 0, 0);
+ yPosition += 6;
+
+ if (
+ registro.distanciaEnderecoEsperado !== null &&
+ registro.distanciaEnderecoEsperado !== undefined &&
+ registro.raioToleranciaUsado !== null &&
+ registro.raioToleranciaUsado !== undefined
+ ) {
+ const distanciaExcedente = registro.distanciaEnderecoEsperado - registro.raioToleranciaUsado;
+ const distanciaExcedenteKm = (distanciaExcedente / 1000).toFixed(2);
+ const distanciaExcedenteMetros = distanciaExcedente.toFixed(0);
+
+ if (distanciaExcedente >= 1000) {
+ doc.text(` ${distanciaExcedenteKm} km além do permitido`, 20, yPosition);
+ } else {
+ doc.text(` ${distanciaExcedenteMetros} metros além do permitido`, 20, yPosition);
+ }
+ }
+
+ yPosition += 6;
+
+ doc.setFont('helvetica', 'normal');
+ doc.setTextColor(0, 0, 0);
+ doc.setFontSize(9);
+ const observacaoLines = doc.splitTextToSize(
+ 'O registro foi realizado fora da área permitida de marcação de ponto. Verifique se o funcionário possui autorização para trabalho remoto ou deslocamento.',
+ 170
+ );
+ doc.text(observacaoLines, 20, yPosition);
+ yPosition += observacaoLines.length * 4;
+ doc.setFontSize(10);
+ } else {
+ doc.text(' Não validado', 20, yPosition);
+ }
+
+ yPosition += 8;
+ } else {
+ doc.text('Validação de localização permitida não configurada para este registro.', 15, yPosition);
+ yPosition += 8;
+ }
+ }
+
// Dados Técnicos
// Verificar se precisa de nova página
if (yPosition > 200) {
@@ -1267,65 +1626,122 @@
}
-
+
-
-
-
-
-
-
-
Registro de Pontos
-
Gerencie e visualize os registros de ponto dos funcionários
+
+
+
+
+
+
+
+
+
+
+ Registro de Pontos
+
+
+ Gerencie e visualize os registros de ponto dos funcionários com informações detalhadas e relatórios
+
+
+ {#if estatisticas}
+
+
+
Total de Registros
+
{estatisticas.totalRegistros}
+
+
+
Funcionários
+
{estatisticas.totalFuncionarios}
+
+
+
+
+ {estatisticas.totalRegistros > 0
+ ? ((estatisticas.dentroDoPrazo / estatisticas.totalRegistros) * 100).toFixed(1)
+ : 0}% dentro do prazo
+
+ Ativo
+
+
+ {/if}
-
+
-
+
{#if estatisticas}
-
-
-
-
-
-
Total de Registros
-
{estatisticas.totalRegistros}
-
-
-
-
-
-
-
Dentro do Prazo
-
{estatisticas.dentroDoPrazo}
-
- {estatisticas.totalRegistros > 0
- ? ((estatisticas.dentroDoPrazo / estatisticas.totalRegistros) * 100).toFixed(1)
- : 0}%
+
+
+
-
-
-
-
-
Fora do Prazo
-
{estatisticas.foraDoPrazo}
-
- {estatisticas.totalRegistros > 0
- ? ((estatisticas.foraDoPrazo / estatisticas.totalRegistros) * 100).toFixed(1)
- : 0}%
+
+
-
-
-
+
+
@@ -1333,13 +1749,17 @@
{#if estatisticas}
-
+
-
-
- Visão Geral das Estatísticas
-
-
+
+
+
+
+
+ Visão Geral das Estatísticas
+
+
+
{#if !chartInstance && estatisticas}
@@ -1347,83 +1767,54 @@
{/if}
-
-
-
Total
-
{estatisticas.totalRegistros}
-
-
-
Dentro do Prazo
-
{estatisticas.dentroDoPrazo}
-
- {estatisticas.totalRegistros > 0
- ? ((estatisticas.dentroDoPrazo / estatisticas.totalRegistros) * 100).toFixed(1)
- : 0}%
-
-
-
-
Fora do Prazo
-
{estatisticas.foraDoPrazo}
-
- {estatisticas.totalRegistros > 0
- ? ((estatisticas.foraDoPrazo / estatisticas.totalRegistros) * 100).toFixed(1)
- : 0}%
-
-
-
-
Funcionários
-
{estatisticas.totalFuncionarios}
-
- {estatisticas.funcionariosDentroPrazo} dentro, {estatisticas.funcionariosForaPrazo} fora
-
-
-
{/if}
-
+
-
-
- Filtros
-
-
+
+
+
+
+
Filtros de Busca
+
+
- Data Início
+ Data Início
- Data Fim
+ Data Fim
- Funcionário
+ Funcionário
- Todos
+ Todos os funcionários
{#each funcionarios as funcionario}
{funcionario.nome}
{/each}
@@ -1434,29 +1825,34 @@
-
+
-
-
Registros
+
+
+
+
+
+
Registros de Ponto
+
{#if funcionarioIdFiltro || dataInicio || dataFim}
{#if funcionarioIdFiltro && funcionarioSelecionadoNome}
-
-
+
+
{funcionarioSelecionadoNome}
{/if}
{#if dataInicio}
-
-
+
+
De: {formatarData(dataInicio)}
{/if}
{#if dataFim}
-
-
+
+
Até: {formatarData(dataFim)}
{/if}
@@ -1465,101 +1861,127 @@
{#if registrosQuery?.status === 'Loading'}
-
-
-
Carregando registros...
+
+
+ Carregando registros...
+ Aguarde um momento
{:else if registrosQuery?.error}
-
-
Erro ao carregar registros: {registrosQuery.error.message || 'Erro desconhecido'}
+
+
+
+
Erro ao carregar registros
+
{registrosQuery.error.message || 'Erro desconhecido'}
+
{:else if !registrosQuery?.data}
-
-
Aguardando dados da consulta...
+
+
+ Aguardando dados da consulta...
{:else if registros.length === 0}
-
-
Nenhum registro encontrado para o período selecionado
-
- Período: {formatarData(dataInicio)} até {formatarData(dataFim)}
- {#if funcionarioIdFiltro && funcionarioSelecionadoNome}
-
- Funcionário: {funcionarioSelecionadoNome}
- {/if}
+
+
+
+
Nenhum registro encontrado
+
+
Período: {formatarData(dataInicio)} até {formatarData(dataFim)}
+ {#if funcionarioIdFiltro && funcionarioSelecionadoNome}
+
Funcionário: {funcionarioSelecionadoNome}
+ {/if}
+
Tente ajustar os filtros para encontrar registros.
+
{:else if registrosAgrupados.length === 0}
-
-
Registros encontrados, mas não foi possível agrupá-los
-
- Total de registros: {registros.length}
+
+
+
+
Registros encontrados, mas não foi possível agrupá-los
+
+ Total de registros: {registros.length}
+
{:else}
-
+
{#each registrosAgrupados as grupo}
-
-
-
+
+
+
-
- {grupo.funcionario?.nome || 'Funcionário não encontrado'}
-
+
+
+
+
+
+ {grupo.funcionario?.nome || 'Funcionário não encontrado'}
+
+
{#if grupo.funcionario?.matricula}
-
- Matrícula: {grupo.funcionario.matricula}
+
+ Matrícula: {grupo.funcionario.matricula}
+
+ {/if}
+ {#if grupo.funcionario?.descricaoCargo}
+
+ {grupo.funcionario.descricaoCargo}
{/if}
-
- {#key grupo.funcionarioId}
- {@const bancoHorasQuery = useQuery(
- api.pontos.obterBancoHorasFuncionario,
- { funcionarioId: grupo.funcionarioId }
- )}
- {@const bancoHoras = bancoHorasQuery?.data}
- {@const saldoAcumulado = bancoHoras?.saldoAcumuladoMinutos ?? 0}
- {@const saldoPositivo = saldoAcumulado >= 0}
-
- {#if bancoHoras}
-
-
- {#if saldoPositivo}
-
- {:else}
-
- {/if}
-
-
Banco de Horas
-
- {formatarSaldoHoras(saldoAcumulado)}
-
+
+
+ {#key grupo.funcionarioId}
+ {@const bancoHorasQuery = useQuery(
+ api.pontos.obterBancoHorasFuncionario,
+ { funcionarioId: grupo.funcionarioId }
+ )}
+ {@const bancoHoras = bancoHorasQuery?.data}
+ {@const saldoAcumulado = bancoHoras?.saldoAcumuladoMinutos ?? 0}
+ {@const saldoPositivo = saldoAcumulado >= 0}
+
+ {#if bancoHoras}
+
+
+
+ {#if saldoPositivo}
+
+ {:else}
+
+ {/if}
+
+
+
Banco de Horas
+
+ {formatarSaldoHoras(saldoAcumulado)}
+
+
-
- {/if}
- {/key}
-
-
abrirModalImpressao(grupo.funcionarioId)}
- >
-
- Imprimir Ficha
-
+ {/if}
+ {/key}
+
+
abrirModalImpressao(grupo.funcionarioId)}
+ >
+
+ Imprimir Ficha
+
+
-
+
-
+
- Data
- Tipo
- Horário
- Saldo Diário
- Status
- Ações
+ Data
+ Tipo
+ Horário
+ Saldo Diário
+ Status
+ Ações
@@ -1585,30 +2007,30 @@
{#if grupoData.saldoDiario}
{formatarSaldoDiario(grupoData.saldoDiario)}
{:else}
- -
+ -
{/if}
{/if}
- {registro.dentroDoPrazo ? 'Dentro do Prazo' : 'Fora do Prazo'}
+ {registro.dentroDoPrazo ? '✓ Dentro do Prazo' : '✗ Fora do Prazo'}
imprimirDetalhesRegistro(registro._id)}
title="Imprimir Detalhes"
>
- Imprimir Detalhes
+ Detalhes
diff --git a/apps/web/src/routes/(dashboard)/ti/configuracoes-ponto/+page.svelte b/apps/web/src/routes/(dashboard)/ti/configuracoes-ponto/+page.svelte
index 31f0f15..c8e1ad7 100644
--- a/apps/web/src/routes/(dashboard)/ti/configuracoes-ponto/+page.svelte
+++ b/apps/web/src/routes/(dashboard)/ti/configuracoes-ponto/+page.svelte
@@ -1,7 +1,8 @@
+
+
+
+
+
+
+
+
+
+
Endereços de Marcação
+
+ Gerenciar locais permitidos para registro de ponto
+
+
+
+
{
+ limparFormulario();
+ mostrarFormulario = true;
+ }}
+ disabled={mostrarFormulario}
+ >
+
+ Novo Endereço
+
+
+
+
+ {#if mostrarFormulario}
+
+
+
+
+
+
+
+
+
+
+ {editandoId ? 'Editar Endereço' : 'Novo Endereço'}
+
+
+ {editandoId ? 'Atualize as informações do endereço' : 'Preencha os dados do novo endereço de marcação'}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 1
+ Informações Básicas
+
+
+
+
+
+
+
+ Nome *
+
+
+
+
+
+
+ Tipo *
+
+
+ 🏢 Sede Principal
+ 🏠 Home Office
+ 🚗 Deslocamento
+ 👥 Cliente
+
+
+
+
+
+
+ Descrição
+ (Opcional)
+
+
+
+
+
+
+
+
+
+
+ 2
+ Endereço Físico
+
+
+
+
+
+
+
+
+ Bairro
+ (Opcional)
+
+
+
+
+
+
+
+
+
+ Cidade *
+
+
+
+
+
+
+ Estado *
+
+
+
+
+
+
+
+ País
+ (Opcional)
+
+
+
+
+
+
+
+
+
+
+ 3
+ Localização GPS e Configuração
+
+
+
+
+
+
+
+
+
+
+
Coordenadas GPS
+
+ As coordenadas são usadas para validar a localização dos registros de ponto. O sistema busca automaticamente via Google Maps (se configurado) ou OpenStreetMap.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Cancelar
+
+
+ {#if processando}
+
+ Salvando...
+ {:else}
+
+ {editandoId ? 'Atualizar Endereço' : 'Criar Endereço'}
+ {/if}
+
+
+
+
+ {/if}
+
+
+
+
+
+
+ {#if enderecosFiltrados.length === 0}
+
+
+
+
+ {termoBusca ? 'Nenhum endereço encontrado' : 'Nenhum endereço cadastrado'}
+
+
+
+ {:else}
+ {#each enderecosFiltrados as enderecoItem (enderecoItem._id)}
+
+
+
+
+
+
{enderecoItem.nome}
+
+ {enderecoItem.ativo ? 'Ativo' : 'Inativo'}
+
+
+ {tiposLabel[enderecoItem.tipo]}
+
+
+
+ {#if enderecoItem.descricao}
+
{enderecoItem.descricao}
+ {/if}
+
+
+
+ Endereço: {enderecoItem.endereco}
+ {#if (enderecoItem as any).bairro}
+ - Bairro: {(enderecoItem as any).bairro}
+ {/if}
+
+
+ Cidade: {enderecoItem.cidade}/{enderecoItem.estado}
+ {#if enderecoItem.cep}
+ - CEP: {enderecoItem.cep}
+ {/if}
+
+
+ Coordenadas: {enderecoItem.latitude.toFixed(6)}, {enderecoItem.longitude.toFixed(6)}
+
+
+ Raio Permitido:
+ {#if enderecoItem.raioMetros >= 1000}
+ {@const raioKm = (enderecoItem.raioMetros / 1000).toFixed(2)}
+ {raioKm} km
+ {:else}
+ {enderecoItem.raioMetros} metros
+ {/if}
+
+
+
+
+
+
abrirFormularioEdicao(enderecoItem)}
+ >
+
+ Editar
+
+ {#if enderecoItem.ativo}
+
desativarEndereco(enderecoItem._id)}
+ >
+
+ Desativar
+
+ {:else}
+
ativarEndereco(enderecoItem._id)}
+ >
+
+ Ativar
+
+ {/if}
+
+
+
+
+ {/each}
+ {/if}
+
+
+
diff --git a/packages/backend/convex/_generated/api.d.ts b/packages/backend/convex/_generated/api.d.ts
index 8e8b485..10444d0 100644
--- a/packages/backend/convex/_generated/api.d.ts
+++ b/packages/backend/convex/_generated/api.d.ts
@@ -28,7 +28,9 @@ import type * as cursos from "../cursos.js";
import type * as dashboard from "../dashboard.js";
import type * as documentos from "../documentos.js";
import type * as email from "../email.js";
+import type * as enderecosMarcacao from "../enderecosMarcacao.js";
import type * as ferias from "../ferias.js";
+import type * as funcionarioEnderecos from "../funcionarioEnderecos.js";
import type * as funcionarios from "../funcionarios.js";
import type * as healthCheck from "../healthCheck.js";
import type * as http from "../http.js";
@@ -80,7 +82,9 @@ declare const fullApi: ApiFromModules<{
dashboard: typeof dashboard;
documentos: typeof documentos;
email: typeof email;
+ enderecosMarcacao: typeof enderecosMarcacao;
ferias: typeof ferias;
+ funcionarioEnderecos: typeof funcionarioEnderecos;
funcionarios: typeof funcionarios;
healthCheck: typeof healthCheck;
http: typeof http;
diff --git a/packages/backend/convex/configuracaoPonto.ts b/packages/backend/convex/configuracaoPonto.ts
index a495a09..8941202 100644
--- a/packages/backend/convex/configuracaoPonto.ts
+++ b/packages/backend/convex/configuracaoPonto.ts
@@ -34,17 +34,21 @@ export const obterConfiguracao = query({
nomeSaidaAlmoco: 'Saída 1',
nomeRetornoAlmoco: 'Entrada 2',
nomeSaida: 'Saída 2',
+ validarLocalizacao: true,
+ toleranciaDistanciaMetros: 100,
ativo: false,
};
}
- // Garantir que os nomes padrão estejam definidos
+ // Garantir que os nomes padrão e valores padrão estejam definidos
return {
...config,
nomeEntrada: config.nomeEntrada || 'Entrada 1',
nomeSaidaAlmoco: config.nomeSaidaAlmoco || 'Saída 1',
nomeRetornoAlmoco: config.nomeRetornoAlmoco || 'Entrada 2',
nomeSaida: config.nomeSaida || 'Saída 2',
+ validarLocalizacao: config.validarLocalizacao ?? true,
+ toleranciaDistanciaMetros: config.toleranciaDistanciaMetros ?? 100,
};
},
});
@@ -63,6 +67,8 @@ export const salvarConfiguracao = mutation({
nomeSaidaAlmoco: v.optional(v.string()),
nomeRetornoAlmoco: v.optional(v.string()),
nomeSaida: v.optional(v.string()),
+ validarLocalizacao: v.optional(v.boolean()),
+ toleranciaDistanciaMetros: v.optional(v.number()),
},
handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx);
@@ -113,6 +119,13 @@ export const salvarConfiguracao = mutation({
throw new Error('Horário de retorno do almoço deve ser anterior à saída');
}
+ // Validar tolerância de distância se fornecida
+ if (args.toleranciaDistanciaMetros !== undefined) {
+ if (args.toleranciaDistanciaMetros < 0 || args.toleranciaDistanciaMetros > 50000) {
+ throw new Error('Tolerância de distância deve estar entre 0 e 50000 metros');
+ }
+ }
+
// Desativar configurações antigas
const configsAntigas = await ctx.db
.query('configuracaoPonto')
@@ -134,6 +147,8 @@ export const salvarConfiguracao = mutation({
nomeSaidaAlmoco: args.nomeSaidaAlmoco || 'Saída 1',
nomeRetornoAlmoco: args.nomeRetornoAlmoco || 'Entrada 2',
nomeSaida: args.nomeSaida || 'Saída 2',
+ validarLocalizacao: args.validarLocalizacao ?? true,
+ toleranciaDistanciaMetros: args.toleranciaDistanciaMetros ?? 100,
ativo: true,
atualizadoPor: usuario._id as Id<'usuarios'>,
atualizadoEm: Date.now(),
diff --git a/packages/backend/convex/enderecosMarcacao.ts b/packages/backend/convex/enderecosMarcacao.ts
new file mode 100644
index 0000000..f8d49bc
--- /dev/null
+++ b/packages/backend/convex/enderecosMarcacao.ts
@@ -0,0 +1,626 @@
+import { v } from 'convex/values';
+import { mutation, query } from './_generated/server';
+import { getCurrentUserFunction } from './auth';
+import type { Id } from './_generated/dataModel';
+import type { MutationCtx } from './_generated/server';
+
+/**
+ * Calcula distância entre duas coordenadas (fórmula de Haversine)
+ * Retorna distância em metros
+ */
+function calcularDistancia(
+ lat1: number,
+ lon1: number,
+ lat2: number,
+ lon2: number
+): number {
+ const R = 6371000; // Raio da Terra em metros
+ const dLat = ((lat2 - lat1) * Math.PI) / 180;
+ const dLon = ((lon2 - lon1) * Math.PI) / 180;
+ const a =
+ Math.sin(dLat / 2) * Math.sin(dLat / 2) +
+ Math.cos((lat1 * Math.PI) / 180) *
+ Math.cos((lat2 * Math.PI) / 180) *
+ Math.sin(dLon / 2) *
+ Math.sin(dLon / 2);
+ const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
+ return R * c;
+}
+
+/**
+ * Lista todos os endereços de marcação ativos
+ */
+export const listarEnderecos = query({
+ args: {
+ incluirInativos: v.optional(v.boolean()),
+ },
+ handler: async (ctx, args) => {
+ const usuario = await getCurrentUserFunction(ctx);
+ if (!usuario) {
+ throw new Error('Usuário não autenticado');
+ }
+
+ let enderecos;
+ if (args.incluirInativos) {
+ enderecos = await ctx.db.query('enderecosMarcacao').collect();
+ } else {
+ enderecos = await ctx.db
+ .query('enderecosMarcacao')
+ .withIndex('by_ativo', (q) => q.eq('ativo', true))
+ .collect();
+ }
+
+ // Ordenar por nome
+ return enderecos.sort((a, b) => a.nome.localeCompare(b.nome));
+ },
+});
+
+/**
+ * Obtém um endereço específico
+ */
+export const obterEndereco = query({
+ args: {
+ enderecoId: v.id('enderecosMarcacao'),
+ },
+ handler: async (ctx, args) => {
+ const usuario = await getCurrentUserFunction(ctx);
+ if (!usuario) {
+ throw new Error('Usuário não autenticado');
+ }
+
+ const endereco = await ctx.db.get(args.enderecoId);
+ if (!endereco) {
+ throw new Error('Endereço não encontrado');
+ }
+
+ return endereco;
+ },
+});
+
+/**
+ * Cria um novo endereço de marcação
+ */
+export const criarEndereco = mutation({
+ args: {
+ nome: v.string(),
+ descricao: v.optional(v.string()),
+ latitude: v.number(),
+ longitude: v.number(),
+ endereco: v.string(),
+ bairro: v.optional(v.string()),
+ cep: v.optional(v.string()),
+ cidade: v.string(),
+ estado: v.string(),
+ pais: v.optional(v.string()),
+ raioMetros: v.number(),
+ tipo: v.union(
+ v.literal('sede'),
+ v.literal('home_office'),
+ v.literal('deslocamento'),
+ v.literal('cliente')
+ ),
+ },
+ handler: async (ctx, args) => {
+ const usuario = await getCurrentUserFunction(ctx);
+ if (!usuario) {
+ throw new Error('Usuário não autenticado');
+ }
+
+ // TODO: Verificar permissões (apenas TI ou admin)
+
+ // Validações
+ if (!args.nome || args.nome.trim().length === 0) {
+ throw new Error('Nome é obrigatório');
+ }
+
+ if (
+ isNaN(args.latitude) ||
+ args.latitude < -90 ||
+ args.latitude > 90 ||
+ isNaN(args.longitude) ||
+ args.longitude < -180 ||
+ args.longitude > 180
+ ) {
+ throw new Error('Coordenadas inválidas');
+ }
+
+ if (args.raioMetros < 0 || args.raioMetros > 50000) {
+ throw new Error('Raio deve estar entre 0 e 50000 metros');
+ }
+
+ if (!args.endereco || args.endereco.trim().length === 0) {
+ throw new Error('Endereço é obrigatório');
+ }
+
+ if (!args.cidade || args.cidade.trim().length === 0) {
+ throw new Error('Cidade é obrigatória');
+ }
+
+ if (!args.estado || args.estado.trim().length === 0) {
+ throw new Error('Estado é obrigatório');
+ }
+
+ const enderecoId = await ctx.db.insert('enderecosMarcacao', {
+ nome: args.nome.trim(),
+ descricao: args.descricao?.trim(),
+ latitude: args.latitude,
+ longitude: args.longitude,
+ endereco: args.endereco.trim(),
+ bairro: args.bairro?.trim(),
+ cep: args.cep?.trim(),
+ cidade: args.cidade.trim(),
+ estado: args.estado.trim(),
+ pais: args.pais?.trim() || 'Brasil',
+ raioMetros: args.raioMetros,
+ tipo: args.tipo,
+ ativo: true,
+ criadoPor: usuario._id as Id<'usuarios'>,
+ criadoEm: Date.now(),
+ });
+
+ return { enderecoId };
+ },
+});
+
+/**
+ * Atualiza um endereço de marcação
+ */
+export const atualizarEndereco = mutation({
+ args: {
+ enderecoId: v.id('enderecosMarcacao'),
+ nome: v.optional(v.string()),
+ descricao: v.optional(v.string()),
+ latitude: v.optional(v.number()),
+ longitude: v.optional(v.number()),
+ endereco: v.optional(v.string()),
+ bairro: v.optional(v.string()),
+ cep: v.optional(v.string()),
+ cidade: v.optional(v.string()),
+ estado: v.optional(v.string()),
+ pais: v.optional(v.string()),
+ raioMetros: v.optional(v.number()),
+ ativo: v.optional(v.boolean()),
+ tipo: v.optional(
+ v.union(
+ v.literal('sede'),
+ v.literal('home_office'),
+ v.literal('deslocamento'),
+ v.literal('cliente')
+ )
+ ),
+ },
+ handler: async (ctx, args) => {
+ const usuario = await getCurrentUserFunction(ctx);
+ if (!usuario) {
+ throw new Error('Usuário não autenticado');
+ }
+
+ // TODO: Verificar permissões (apenas TI ou admin)
+
+ const endereco = await ctx.db.get(args.enderecoId);
+ if (!endereco) {
+ throw new Error('Endereço não encontrado');
+ }
+
+ const atualizacoes: {
+ nome?: string;
+ descricao?: string;
+ latitude?: number;
+ longitude?: number;
+ endereco?: string;
+ cep?: string;
+ cidade?: string;
+ estado?: string;
+ pais?: string;
+ raioMetros?: number;
+ tipo?: 'sede' | 'home_office' | 'deslocamento' | 'cliente';
+ ativo?: boolean;
+ atualizadoPor?: Id<'usuarios'>;
+ atualizadoEm?: number;
+ } = {};
+
+ if (args.nome !== undefined) {
+ if (args.nome.trim().length === 0) {
+ throw new Error('Nome não pode ser vazio');
+ }
+ atualizacoes.nome = args.nome.trim();
+ }
+
+ if (args.descricao !== undefined) {
+ atualizacoes.descricao = args.descricao?.trim() || undefined;
+ }
+
+ if (args.latitude !== undefined || args.longitude !== undefined) {
+ const lat = args.latitude ?? endereco.latitude;
+ const lon = args.longitude ?? endereco.longitude;
+
+ if (
+ isNaN(lat) ||
+ lat < -90 ||
+ lat > 90 ||
+ isNaN(lon) ||
+ lon < -180 ||
+ lon > 180
+ ) {
+ throw new Error('Coordenadas inválidas');
+ }
+
+ if (args.latitude !== undefined) atualizacoes.latitude = lat;
+ if (args.longitude !== undefined) atualizacoes.longitude = lon;
+ }
+
+ if (args.endereco !== undefined) {
+ if (args.endereco.trim().length === 0) {
+ throw new Error('Endereço não pode ser vazio');
+ }
+ atualizacoes.endereco = args.endereco.trim();
+ }
+
+ if (args.bairro !== undefined) {
+ atualizacoes.bairro = args.bairro?.trim() || undefined;
+ }
+
+ if (args.cep !== undefined) {
+ atualizacoes.cep = args.cep?.trim() || undefined;
+ }
+
+ if (args.cidade !== undefined) {
+ if (args.cidade.trim().length === 0) {
+ throw new Error('Cidade não pode ser vazia');
+ }
+ atualizacoes.cidade = args.cidade.trim();
+ }
+
+ if (args.estado !== undefined) {
+ if (args.estado.trim().length === 0) {
+ throw new Error('Estado não pode ser vazio');
+ }
+ atualizacoes.estado = args.estado.trim();
+ }
+
+ if (args.pais !== undefined) {
+ atualizacoes.pais = args.pais?.trim() || 'Brasil';
+ }
+
+ if (args.raioMetros !== undefined) {
+ if (args.raioMetros < 0 || args.raioMetros > 50000) {
+ throw new Error('Raio deve estar entre 0 e 50000 metros');
+ }
+ atualizacoes.raioMetros = args.raioMetros;
+ }
+
+ if (args.tipo !== undefined) {
+ atualizacoes.tipo = args.tipo;
+ }
+
+ if (args.ativo !== undefined) {
+ atualizacoes.ativo = args.ativo;
+ }
+
+ atualizacoes.atualizadoPor = usuario._id as Id<'usuarios'>;
+ atualizacoes.atualizadoEm = Date.now();
+
+ await ctx.db.patch(args.enderecoId, atualizacoes);
+
+ return { sucesso: true };
+ },
+});
+
+/**
+ * Desativa um endereço de marcação
+ */
+export const desativarEndereco = mutation({
+ args: {
+ enderecoId: v.id('enderecosMarcacao'),
+ },
+ handler: async (ctx, args) => {
+ const usuario = await getCurrentUserFunction(ctx);
+ if (!usuario) {
+ throw new Error('Usuário não autenticado');
+ }
+
+ // TODO: Verificar permissões (apenas TI ou admin)
+
+ const endereco = await ctx.db.get(args.enderecoId);
+ if (!endereco) {
+ throw new Error('Endereço não encontrado');
+ }
+
+ await ctx.db.patch(args.enderecoId, {
+ ativo: false,
+ atualizadoPor: usuario._id as Id<'usuarios'>,
+ atualizadoEm: Date.now(),
+ });
+
+ return { sucesso: true };
+ },
+});
+
+/**
+ * Obtém endereços permitidos para um funcionário
+ * (leva em conta associações específicas e endereços tipo "sede")
+ */
+export const obterEnderecosFuncionario = query({
+ args: {
+ funcionarioId: v.id('funcionarios'),
+ dataAtual: v.optional(v.string()), // YYYY-MM-DD para validar períodos
+ },
+ handler: async (ctx, args) => {
+ const usuario = await getCurrentUserFunction(ctx);
+ if (!usuario) {
+ throw new Error('Usuário não autenticado');
+ }
+
+ const dataAtual = args.dataAtual || new Date().toISOString().split('T')[0]!;
+
+ // Buscar associações específicas do funcionário
+ const associacoes = await ctx.db
+ .query('funcionarioEnderecosMarcacao')
+ .withIndex('by_funcionario_ativo', (q) =>
+ q.eq('funcionarioId', args.funcionarioId).eq('ativo', true)
+ )
+ .collect();
+
+ const enderecosPermitidos: Array<{
+ enderecoId: Id<'enderecosMarcacao'>;
+ raioMetros: number;
+ periodoValido: boolean;
+ }> = [];
+
+ // Processar associações
+ for (const associacao of associacoes) {
+ // Verificar período de validade
+ let periodoValido = true;
+ if (associacao.dataInicio || associacao.dataFim) {
+ if (associacao.dataInicio && dataAtual < associacao.dataInicio) {
+ periodoValido = false;
+ }
+ if (associacao.dataFim && dataAtual > associacao.dataFim) {
+ periodoValido = false;
+ }
+ }
+
+ if (!periodoValido) continue;
+
+ const endereco = await ctx.db.get(associacao.enderecoMarcacaoId);
+ if (!endereco || !endereco.ativo) continue;
+
+ // Usar raio personalizado se disponível, senão usar o raio do endereço
+ const raioMetros =
+ associacao.raioMetrosPersonalizado ?? endereco.raioMetros;
+
+ enderecosPermitidos.push({
+ enderecoId: endereco._id,
+ raioMetros,
+ periodoValido: true,
+ });
+ }
+
+ // Se não houver associações específicas, buscar endereços tipo "sede"
+ if (enderecosPermitidos.length === 0) {
+ const enderecosSede = await ctx.db
+ .query('enderecosMarcacao')
+ .withIndex('by_tipo', (q) => q.eq('tipo', 'sede'))
+ .filter((q) => q.eq(q.field('ativo'), true))
+ .collect();
+
+ for (const endereco of enderecosSede) {
+ enderecosPermitidos.push({
+ enderecoId: endereco._id,
+ raioMetros: endereco.raioMetros,
+ periodoValido: true,
+ });
+ }
+ }
+
+ // Buscar dados completos dos endereços
+ const enderecosCompletos = await Promise.all(
+ enderecosPermitidos.map(async (item) => {
+ const endereco = await ctx.db.get(item.enderecoId);
+ if (!endereco) return null;
+
+ return {
+ ...endereco,
+ raioMetros: item.raioMetros,
+ periodoValido: item.periodoValido,
+ };
+ })
+ );
+
+ return enderecosCompletos.filter(
+ (endereco): endereco is NonNullable => endereco !== null
+ );
+ },
+});
+
+/**
+ * Função auxiliar interna para validar geofencing
+ * Pode ser chamada diretamente de outras mutations
+ */
+async function validarLocalizacaoGeofencingInternal(
+ ctx: MutationCtx,
+ funcionarioId: Id<'funcionarios'>,
+ latitude: number,
+ longitude: number,
+ raioPadrao: number = 100
+): Promise<{
+ dentroRaio: boolean;
+ enderecoMaisProximo?: Id<'enderecosMarcacao'>;
+ distanciaMetros?: number;
+ raioUsado?: number;
+ enderecoEncontrado?: string;
+ avisos: string[];
+}> {
+ const avisos: string[] = [];
+
+ // Validar coordenadas
+ if (
+ isNaN(latitude) ||
+ latitude < -90 ||
+ latitude > 90 ||
+ isNaN(longitude) ||
+ longitude < -180 ||
+ longitude > 180
+ ) {
+ return {
+ dentroRaio: false,
+ avisos: ['Coordenadas inválidas'],
+ };
+ }
+
+ // Obter endereços permitidos para o funcionário
+ const dataAtual = new Date().toISOString().split('T')[0]!;
+ const associacoes = await ctx.db
+ .query('funcionarioEnderecosMarcacao')
+ .withIndex('by_funcionario_ativo', (q) =>
+ q.eq('funcionarioId', funcionarioId).eq('ativo', true)
+ )
+ .collect();
+
+ const enderecosParaValidar: Array<{
+ enderecoId: Id<'enderecosMarcacao'>;
+ raioMetros: number;
+ }> = [];
+
+ // Processar associações específicas
+ for (const associacao of associacoes) {
+ let periodoValido = true;
+ if (associacao.dataInicio || associacao.dataFim) {
+ if (associacao.dataInicio && dataAtual < associacao.dataInicio) {
+ periodoValido = false;
+ }
+ if (associacao.dataFim && dataAtual > associacao.dataFim) {
+ periodoValido = false;
+ }
+ }
+
+ if (!periodoValido) continue;
+
+ const endereco = await ctx.db.get(associacao.enderecoMarcacaoId);
+ if (!endereco || !endereco.ativo) continue;
+
+ const raioMetros =
+ associacao.raioMetrosPersonalizado ?? endereco.raioMetros;
+
+ enderecosParaValidar.push({
+ enderecoId: endereco._id,
+ raioMetros,
+ });
+ }
+
+ // Se não houver associações específicas, buscar endereços tipo "sede"
+ if (enderecosParaValidar.length === 0) {
+ const enderecosSede = await ctx.db
+ .query('enderecosMarcacao')
+ .withIndex('by_tipo', (q) => q.eq('tipo', 'sede'))
+ .filter((q) => q.eq(q.field('ativo'), true))
+ .collect();
+
+ for (const endereco of enderecosSede) {
+ enderecosParaValidar.push({
+ enderecoId: endereco._id,
+ raioMetros: endereco.raioMetros,
+ });
+ }
+ }
+
+ // Se ainda não houver endereços, usar raio padrão
+ if (enderecosParaValidar.length === 0) {
+ avisos.push(
+ 'Nenhum endereço de marcação configurado. Usando validação padrão.'
+ );
+ return {
+ dentroRaio: true, // Não bloquear se não houver configuração
+ raioUsado: raioPadrao,
+ avisos,
+ };
+ }
+
+ // Calcular distância para cada endereço e encontrar o mais próximo
+ let enderecoMaisProximo: Id<'enderecosMarcacao'> | undefined = undefined;
+ let distanciaMinima: number | undefined = undefined;
+ let raioUsado: number | undefined = undefined;
+ let enderecoEncontrado: string | undefined = undefined;
+
+ for (const item of enderecosParaValidar) {
+ const endereco = await ctx.db.get(item.enderecoId);
+ if (!endereco) continue;
+
+ const distancia = calcularDistancia(
+ latitude,
+ longitude,
+ endereco.latitude,
+ endereco.longitude
+ );
+
+ if (distanciaMinima === undefined || distancia < distanciaMinima) {
+ distanciaMinima = distancia;
+ enderecoMaisProximo = endereco._id;
+ raioUsado = item.raioMetros;
+ enderecoEncontrado = `${endereco.endereco}, ${endereco.cidade}/${endereco.estado}`;
+ }
+ }
+
+ if (enderecoMaisProximo === undefined || distanciaMinima === undefined) {
+ return {
+ dentroRaio: false,
+ avisos: ['Não foi possível validar localização'],
+ };
+ }
+
+ const dentroRaio = distanciaMinima <= (raioUsado ?? raioPadrao);
+
+ if (!dentroRaio) {
+ const distanciaKm = (distanciaMinima / 1000).toFixed(2);
+ const raioKm = ((raioUsado ?? raioPadrao) / 1000).toFixed(2);
+ avisos.push(
+ `Localização fora do raio permitido. Registrado a ${distanciaKm}km do endereço esperado (raio permitido: ${raioKm}km).`
+ );
+ }
+
+ return {
+ dentroRaio,
+ enderecoMaisProximo,
+ distanciaMetros: distanciaMinima,
+ raioUsado: raioUsado ?? raioPadrao,
+ enderecoEncontrado,
+ avisos,
+ };
+}
+
+/**
+ * Mutation pública para validar localização contra endereços permitidos (geofencing)
+ * Retorna o endereço mais próximo e se está dentro do raio
+ */
+export const validarLocalizacaoGeofencing = mutation({
+ args: {
+ funcionarioId: v.id('funcionarios'),
+ latitude: v.number(),
+ longitude: v.number(),
+ raioPadrao: v.optional(v.number()), // metros - usado se não houver endereços configurados
+ },
+ returns: v.object({
+ dentroRaio: v.boolean(),
+ enderecoMaisProximo: v.optional(v.id('enderecosMarcacao')),
+ distanciaMetros: v.optional(v.number()),
+ raioUsado: v.optional(v.number()),
+ enderecoEncontrado: v.optional(v.string()),
+ avisos: v.array(v.string()),
+ }),
+ handler: async (ctx, args) => {
+ return await validarLocalizacaoGeofencingInternal(
+ ctx,
+ args.funcionarioId,
+ args.latitude,
+ args.longitude,
+ args.raioPadrao || 100
+ );
+ },
+});
+
+/**
+ * Exporta a função auxiliar para uso interno em outras mutations
+ */
+export { validarLocalizacaoGeofencingInternal };
+
+
diff --git a/packages/backend/convex/funcionarioEnderecos.ts b/packages/backend/convex/funcionarioEnderecos.ts
new file mode 100644
index 0000000..d11d6a9
--- /dev/null
+++ b/packages/backend/convex/funcionarioEnderecos.ts
@@ -0,0 +1,246 @@
+import { v } from 'convex/values';
+import { mutation, query } from './_generated/server';
+import { getCurrentUserFunction } from './auth';
+import type { Id } from './_generated/dataModel';
+
+/**
+ * Lista todas as associações de endereços para um funcionário
+ */
+export const listarAssociacoesFuncionario = query({
+ args: {
+ funcionarioId: v.id('funcionarios'),
+ incluirInativos: v.optional(v.boolean()),
+ },
+ handler: async (ctx, args) => {
+ const usuario = await getCurrentUserFunction(ctx);
+ if (!usuario) {
+ throw new Error('Usuário não autenticado');
+ }
+
+ let associacoes;
+ if (args.incluirInativos) {
+ associacoes = await ctx.db
+ .query('funcionarioEnderecosMarcacao')
+ .withIndex('by_funcionario', (q) => q.eq('funcionarioId', args.funcionarioId))
+ .collect();
+ } else {
+ associacoes = await ctx.db
+ .query('funcionarioEnderecosMarcacao')
+ .withIndex('by_funcionario_ativo', (q) =>
+ q.eq('funcionarioId', args.funcionarioId).eq('ativo', true)
+ )
+ .collect();
+ }
+
+ // Buscar dados completos dos endereços
+ const associacoesCompletas = await Promise.all(
+ associacoes.map(async (associacao) => {
+ const endereco = await ctx.db.get(associacao.enderecoMarcacaoId);
+ if (!endereco) return null;
+
+ // Usar raio personalizado se disponível, senão usar o raio padrão do endereço
+ const raioMetros = associacao.raioMetrosPersonalizado ?? endereco.raioMetros;
+
+ return {
+ ...associacao,
+ raioMetros, // Raio usado (personalizado ou padrão)
+ endereco: {
+ _id: endereco._id,
+ nome: endereco.nome,
+ descricao: endereco.descricao,
+ latitude: endereco.latitude,
+ longitude: endereco.longitude,
+ endereco: endereco.endereco,
+ cep: endereco.cep,
+ cidade: endereco.cidade,
+ estado: endereco.estado,
+ pais: endereco.pais,
+ raioMetros: endereco.raioMetros, // Raio padrão do endereço
+ tipo: endereco.tipo,
+ ativo: endereco.ativo,
+ },
+ };
+ })
+ );
+
+ return associacoesCompletas.filter(
+ (item): item is NonNullable => item !== null
+ );
+ },
+});
+
+/**
+ * Associa um endereço a um funcionário
+ */
+export const associarEnderecoFuncionario = mutation({
+ args: {
+ funcionarioId: v.id('funcionarios'),
+ enderecoMarcacaoId: v.id('enderecosMarcacao'),
+ raioMetrosPersonalizado: v.optional(v.number()),
+ dataInicio: v.optional(v.string()), // YYYY-MM-DD
+ dataFim: v.optional(v.string()), // YYYY-MM-DD
+ },
+ handler: async (ctx, args) => {
+ const usuario = await getCurrentUserFunction(ctx);
+ if (!usuario) {
+ throw new Error('Usuário não autenticado');
+ }
+
+ // TODO: Verificar permissões (apenas TI ou admin)
+
+ // Validar que o funcionário existe
+ const funcionario = await ctx.db.get(args.funcionarioId);
+ if (!funcionario) {
+ throw new Error('Funcionário não encontrado');
+ }
+
+ // Validar que o endereço existe e está ativo
+ const endereco = await ctx.db.get(args.enderecoMarcacaoId);
+ if (!endereco) {
+ throw new Error('Endereço não encontrado');
+ }
+ if (!endereco.ativo) {
+ throw new Error('Endereço não está ativo');
+ }
+
+ // Validar raio personalizado se fornecido
+ if (args.raioMetrosPersonalizado !== undefined) {
+ if (args.raioMetrosPersonalizado < 0 || args.raioMetrosPersonalizado > 50000) {
+ throw new Error('Raio deve estar entre 0 e 50000 metros');
+ }
+ }
+
+ // Validar datas se fornecidas
+ if (args.dataInicio && args.dataFim) {
+ if (args.dataInicio > args.dataFim) {
+ throw new Error('Data de início deve ser anterior à data de fim');
+ }
+ }
+
+ // Verificar se já existe uma associação ativa
+ const associacaoExistente = await ctx.db
+ .query('funcionarioEnderecosMarcacao')
+ .withIndex('by_funcionario_ativo', (q) =>
+ q.eq('funcionarioId', args.funcionarioId).eq('ativo', true)
+ )
+ .filter((q) => q.eq(q.field('enderecoMarcacaoId'), args.enderecoMarcacaoId))
+ .first();
+
+ if (associacaoExistente) {
+ // Atualizar associação existente
+ await ctx.db.patch(associacaoExistente._id, {
+ raioMetrosPersonalizado: args.raioMetrosPersonalizado,
+ dataInicio: args.dataInicio,
+ dataFim: args.dataFim,
+ ativo: true,
+ });
+
+ return { associacaoId: associacaoExistente._id, atualizado: true };
+ }
+
+ // Criar nova associação
+ const associacaoId = await ctx.db.insert('funcionarioEnderecosMarcacao', {
+ funcionarioId: args.funcionarioId,
+ enderecoMarcacaoId: args.enderecoMarcacaoId,
+ raioMetrosPersonalizado: args.raioMetrosPersonalizado,
+ dataInicio: args.dataInicio,
+ dataFim: args.dataFim,
+ ativo: true,
+ criadoPor: usuario._id as Id<'usuarios'>,
+ criadoEm: Date.now(),
+ });
+
+ return { associacaoId, atualizado: false };
+ },
+});
+
+/**
+ * Atualiza uma associação de endereço com funcionário
+ */
+export const atualizarAssociacao = mutation({
+ args: {
+ associacaoId: v.id('funcionarioEnderecosMarcacao'),
+ raioMetrosPersonalizado: v.optional(v.number()),
+ dataInicio: v.optional(v.string()),
+ dataFim: v.optional(v.string()),
+ ativo: v.optional(v.boolean()),
+ },
+ handler: async (ctx, args) => {
+ const usuario = await getCurrentUserFunction(ctx);
+ if (!usuario) {
+ throw new Error('Usuário não autenticado');
+ }
+
+ // TODO: Verificar permissões (apenas TI ou admin)
+
+ const associacao = await ctx.db.get(args.associacaoId);
+ if (!associacao) {
+ throw new Error('Associação não encontrada');
+ }
+
+ const atualizacoes: {
+ raioMetrosPersonalizado?: number;
+ dataInicio?: string;
+ dataFim?: string;
+ ativo?: boolean;
+ } = {};
+
+ if (args.raioMetrosPersonalizado !== undefined) {
+ if (args.raioMetrosPersonalizado < 0 || args.raioMetrosPersonalizado > 50000) {
+ throw new Error('Raio deve estar entre 0 e 50000 metros');
+ }
+ atualizacoes.raioMetrosPersonalizado = args.raioMetrosPersonalizado;
+ }
+
+ if (args.dataInicio !== undefined) {
+ atualizacoes.dataInicio = args.dataInicio || undefined;
+ }
+
+ if (args.dataFim !== undefined) {
+ atualizacoes.dataFim = args.dataFim || undefined;
+ }
+
+ if (args.dataInicio && args.dataFim) {
+ if (args.dataInicio > args.dataFim) {
+ throw new Error('Data de início deve ser anterior à data de fim');
+ }
+ }
+
+ if (args.ativo !== undefined) {
+ atualizacoes.ativo = args.ativo;
+ }
+
+ await ctx.db.patch(args.associacaoId, atualizacoes);
+
+ return { sucesso: true };
+ },
+});
+
+/**
+ * Remove uma associação de endereço com funcionário (desativa)
+ */
+export const removerAssociacao = mutation({
+ args: {
+ associacaoId: v.id('funcionarioEnderecosMarcacao'),
+ },
+ handler: async (ctx, args) => {
+ const usuario = await getCurrentUserFunction(ctx);
+ if (!usuario) {
+ throw new Error('Usuário não autenticado');
+ }
+
+ // TODO: Verificar permissões (apenas TI ou admin)
+
+ const associacao = await ctx.db.get(args.associacaoId);
+ if (!associacao) {
+ throw new Error('Associação não encontrada');
+ }
+
+ // Desativar ao invés de deletar
+ await ctx.db.patch(args.associacaoId, {
+ ativo: false,
+ });
+
+ return { sucesso: true };
+ },
+});
diff --git a/packages/backend/convex/pontos.ts b/packages/backend/convex/pontos.ts
index 05f23cc..5d1060a 100644
--- a/packages/backend/convex/pontos.ts
+++ b/packages/backend/convex/pontos.ts
@@ -3,6 +3,225 @@ import { mutation, query } from './_generated/server';
import type { MutationCtx, QueryCtx } from './_generated/server';
import { getCurrentUserFunction } from './auth';
import type { Id } from './_generated/dataModel';
+import { validarLocalizacaoGeofencingInternal } from './enderecosMarcacao';
+
+/**
+ * Calcula distância entre duas coordenadas (fórmula de Haversine)
+ * Retorna distância em metros
+ */
+function calcularDistancia(
+ lat1: number,
+ lon1: number,
+ lat2: number,
+ lon2: number
+): number {
+ const R = 6371000; // Raio da Terra em metros
+ const dLat = ((lat2 - lat1) * Math.PI) / 180;
+ const dLon = ((lon2 - lon1) * Math.PI) / 180;
+ const a =
+ Math.sin(dLat / 2) * Math.sin(dLat / 2) +
+ Math.cos((lat1 * Math.PI) / 180) *
+ Math.cos((lat2 * Math.PI) / 180) *
+ Math.sin(dLon / 2) *
+ Math.sin(dLon / 2);
+ const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
+ return R * c;
+}
+
+/**
+ * Obtém geolocalização aproximada por IP usando serviço externo
+ */
+async function obterGeoPorIP(ipAddress: string): Promise<{
+ latitude: number;
+ longitude: number;
+ cidade?: string;
+ estado?: string;
+ pais?: string;
+} | null> {
+ try {
+ // Usar ipapi.co (gratuito, sem chave para uso limitado)
+ const response = await fetch(`https://ipapi.co/${ipAddress}/json/`, {
+ headers: {
+ 'User-Agent': 'SGSE-App/1.0'
+ }
+ });
+
+ if (response.ok) {
+ const data = (await response.json()) as {
+ latitude?: number;
+ longitude?: number;
+ city?: string;
+ region?: string;
+ country_name?: string;
+ error?: boolean;
+ };
+
+ if (!data.error && data.latitude && data.longitude) {
+ return {
+ latitude: data.latitude,
+ longitude: data.longitude,
+ cidade: data.city,
+ estado: data.region,
+ pais: data.country_name
+ };
+ }
+ }
+ } catch (error) {
+ console.warn('Erro ao obter geolocalização por IP:', error);
+ }
+
+ return null;
+}
+
+/**
+ * Valida localização contra IP geolocation e histórico
+ * Retorna informações detalhadas para salvar no registro
+ */
+async function validarLocalizacao(
+ ctx: MutationCtx,
+ funcionarioId: Id<'funcionarios'>,
+ latitude: number,
+ longitude: number,
+ ipAddress?: string,
+ confiabilidadeGPS?: number
+): Promise<{
+ valida: boolean;
+ motivo?: string;
+ scoreConfianca: number; // 0-1
+ avisos: string[];
+ distanciaIPvsGPS?: number; // Distância em metros entre IP geolocation e GPS
+ velocidadeUltimoRegistro?: number; // Velocidade calculada em km/h
+ distanciaUltimoRegistro?: number; // Distância em metros do último registro
+ tempoDecorridoHoras?: number; // Tempo em horas desde último registro
+}> {
+ const avisos: string[] = [];
+ let scoreConfianca = confiabilidadeGPS || 0.5;
+ let valida = true;
+ let distanciaIPvsGPS: number | undefined = undefined;
+ let velocidadeUltimoRegistro: number | undefined = undefined;
+ let distanciaUltimoRegistro: number | undefined = undefined;
+ let tempoDecorridoHoras: number | undefined = undefined;
+
+ // 1. Validar coordenadas básicas
+ if (
+ isNaN(latitude) ||
+ isNaN(longitude) ||
+ latitude < -90 ||
+ latitude > 90 ||
+ longitude < -180 ||
+ longitude > 180
+ ) {
+ return {
+ valida: false,
+ motivo: 'Coordenadas inválidas',
+ scoreConfianca: 0,
+ avisos: [],
+ distanciaIPvsGPS,
+ velocidadeUltimoRegistro,
+ distanciaUltimoRegistro,
+ tempoDecorridoHoras
+ };
+ }
+
+ // 2. Comparar com geolocalização do IP
+ if (ipAddress) {
+ const ipGeo = await obterGeoPorIP(ipAddress);
+ if (ipGeo) {
+ distanciaIPvsGPS = calcularDistancia(
+ latitude,
+ longitude,
+ ipGeo.latitude,
+ ipGeo.longitude
+ );
+
+ // Se diferença > 50km, muito suspeito
+ if (distanciaIPvsGPS > 50000) {
+ valida = false;
+ scoreConfianca = Math.min(scoreConfianca, 0.2);
+ avisos.push(
+ `Localização GPS (${latitude.toFixed(6)}, ${longitude.toFixed(6)}) está muito distante da localização do IP (${distanciaIPvsGPS.toFixed(0)}m). Possível falsificação.`
+ );
+ } else if (distanciaIPvsGPS > 10000) {
+ // Se diferença entre 10-50km, suspeito mas aceitável (pode ser VPN/mobile)
+ scoreConfianca *= 0.7;
+ avisos.push(
+ `Localização GPS está a ${distanciaIPvsGPS.toFixed(0)}m da localização do IP. Isso pode ser normal se estiver usando VPN ou dados móveis.`
+ );
+ } else if (distanciaIPvsGPS < 5000) {
+ // Se diferença < 5km, aumenta confiança
+ scoreConfianca = Math.min(scoreConfianca + 0.2, 1);
+ }
+ }
+ }
+
+ // 3. Validar histórico de localizações do funcionário
+ const ultimosRegistros = await ctx.db
+ .query('registrosPonto')
+ .withIndex('by_funcionario_data', (q) => q.eq('funcionarioId', funcionarioId))
+ .order('desc')
+ .take(5);
+
+ if (ultimosRegistros.length > 0) {
+ // Verificar movimento impossível
+ for (const registro of ultimosRegistros) {
+ if (registro.latitude && registro.longitude && registro.timestamp) {
+ distanciaUltimoRegistro = calcularDistancia(
+ latitude,
+ longitude,
+ registro.latitude,
+ registro.longitude
+ );
+ const tempoDecorrido = Date.now() - registro.timestamp;
+ tempoDecorridoHoras = tempoDecorrido / (1000 * 60 * 60);
+
+ // Calcular velocidade (km/h) se tempo decorrido > 0
+ if (tempoDecorridoHoras > 0 && tempoDecorridoHoras < 24) {
+ velocidadeUltimoRegistro = (distanciaUltimoRegistro / 1000) / tempoDecorridoHoras; // km/h
+
+ // Se velocidade > 1000 km/h, impossível (mais rápido que avião)
+ if (velocidadeUltimoRegistro > 1000) {
+ valida = false;
+ scoreConfianca = 0;
+ avisos.push(
+ `Movimento impossível detectado: ${velocidadeUltimoRegistro.toFixed(0)} km/h. Localização anterior há ${tempoDecorridoHoras.toFixed(1)}h está a ${(distanciaUltimoRegistro / 1000).toFixed(1)}km.`
+ );
+ break;
+ }
+
+ // Se velocidade > 200 km/h, suspeito (mas possível em avião)
+ if (velocidadeUltimoRegistro > 200 && velocidadeUltimoRegistro <= 1000) {
+ scoreConfianca *= 0.6;
+ avisos.push(
+ `Movimento muito rápido: ${velocidadeUltimoRegistro.toFixed(0)} km/h. Pode ser viagem, mas verifique se é legítimo.`
+ );
+ }
+ }
+ break; // Usar apenas o último registro
+ }
+ }
+ }
+
+ // 4. Validar confiabilidade GPS do frontend
+ if (confiabilidadeGPS !== undefined) {
+ if (confiabilidadeGPS < 0.3) {
+ scoreConfianca *= 0.5;
+ avisos.push(
+ `Confiabilidade GPS baixa (${(confiabilidadeGPS * 100).toFixed(0)}%). Localização pode não ser precisa.`
+ );
+ }
+ }
+
+ return {
+ valida,
+ motivo: avisos.length > 0 ? avisos[0] : undefined,
+ scoreConfianca: Math.max(0, Math.min(1, scoreConfianca)),
+ avisos,
+ distanciaIPvsGPS,
+ velocidadeUltimoRegistro,
+ distanciaUltimoRegistro,
+ tempoDecorridoHoras
+ };
+}
/**
* Gera URL para upload de imagem do ponto
@@ -96,6 +315,13 @@ export const registrarPonto = mutation({
latitude: v.optional(v.number()),
longitude: v.optional(v.number()),
precisao: v.optional(v.number()),
+ altitude: v.optional(v.union(v.number(), v.null())),
+ altitudeAccuracy: v.optional(v.union(v.number(), v.null())),
+ heading: v.optional(v.union(v.number(), v.null())),
+ speed: v.optional(v.union(v.number(), v.null())),
+ confiabilidadeGPS: v.optional(v.number()),
+ suspeitaSpoofing: v.optional(v.boolean()),
+ motivoSuspeita: v.optional(v.string()),
endereco: v.optional(v.string()),
cidade: v.optional(v.string()),
estado: v.optional(v.string()),
@@ -150,13 +376,31 @@ export const registrarPonto = mutation({
.first();
// Converter timestamp para data/hora com ajuste de GMT
+ // O timestamp está em UTC, precisamos aplicar o GMT offset
const gmtOffset = configPonto?.gmtOffset ?? 0;
- const timestampAjustado = args.timestamp + (gmtOffset * 60 * 60 * 1000);
- const dataObj = new Date(timestampAjustado);
- const data = dataObj.toISOString().split('T')[0]!; // YYYY-MM-DD
- const hora = dataObj.getUTCHours();
- const minuto = dataObj.getUTCMinutes();
- const segundo = dataObj.getUTCSeconds();
+
+ // Calcular horário ajustado manualmente a partir de UTC
+ const dataUTC = new Date(args.timestamp);
+ let hora = dataUTC.getUTCHours() + gmtOffset;
+ const minuto = dataUTC.getUTCMinutes();
+ const segundo = dataUTC.getUTCSeconds();
+
+ // Ajustar hora se ultrapassar os limites do dia
+ let diasOffset = 0;
+ if (hora >= 24) {
+ hora = hora - 24;
+ diasOffset = 1;
+ } else if (hora < 0) {
+ hora = hora + 24;
+ diasOffset = -1;
+ }
+
+ // Calcular data ajustada
+ const dataAjustada = new Date(args.timestamp);
+ if (diasOffset !== 0) {
+ dataAjustada.setUTCDate(dataAjustada.getUTCDate() + diasOffset);
+ }
+ const data = dataAjustada.toISOString().split('T')[0]!; // YYYY-MM-DD
// Verificar se já existe registro no mesmo minuto
const funcionarioId = usuario.funcionarioId; // Já verificado acima, não é undefined
@@ -244,6 +488,97 @@ export const registrarPonto = mutation({
const dentroDoPrazo = calcularStatusPonto(hora, minuto, horarioConfigurado, config.toleranciaMinutos);
+ // Validar localização se fornecida e salvar informações detalhadas
+ let validacaoLocalizacao: {
+ valida: boolean;
+ motivo?: string;
+ scoreConfianca: number;
+ avisos: string[];
+ distanciaIPvsGPS?: number;
+ velocidadeUltimoRegistro?: number;
+ distanciaUltimoRegistro?: number;
+ tempoDecorridoHoras?: number;
+ } | null = null;
+
+ if (
+ args.informacoesDispositivo?.latitude &&
+ args.informacoesDispositivo?.longitude
+ ) {
+ validacaoLocalizacao = await validarLocalizacao(
+ ctx,
+ usuario.funcionarioId,
+ args.informacoesDispositivo.latitude,
+ args.informacoesDispositivo.longitude,
+ args.informacoesDispositivo.ipPublico || args.informacoesDispositivo.ipAddress,
+ args.informacoesDispositivo.confiabilidadeGPS
+ );
+
+ // Sempre registrar, mesmo com baixa confiabilidade
+ // Mas salvar todas as informações detalhadas para análise posterior
+ const suspeitaFrontend = args.informacoesDispositivo.suspeitaSpoofing;
+ const suspeitaBackend = !validacaoLocalizacao.valida;
+ const baixaConfianca = validacaoLocalizacao.scoreConfianca < 0.5;
+
+ if (suspeitaFrontend || suspeitaBackend || baixaConfianca) {
+ console.warn('⚠️ LOCALIZAÇÃO COM BAIXA CONFIABILIDADE DETECTADA (registrando normalmente):', {
+ funcionarioId: usuario.funcionarioId,
+ latitude: args.informacoesDispositivo.latitude,
+ longitude: args.informacoesDispositivo.longitude,
+ confiabilidadeGPSFrontend: args.informacoesDispositivo.confiabilidadeGPS,
+ scoreConfiancaBackend: validacaoLocalizacao.scoreConfianca,
+ suspeitaFrontend: suspeitaFrontend ? args.informacoesDispositivo.motivoSuspeita : null,
+ suspeitaBackend: suspeitaBackend ? validacaoLocalizacao.motivo : null,
+ avisos: validacaoLocalizacao.avisos
+ });
+ }
+ }
+
+ // Validar geofencing (localização permitida) se habilitado
+ let validacaoGeofencing: {
+ dentroRaio: boolean;
+ enderecoMaisProximo?: Id<'enderecosMarcacao'>;
+ distanciaMetros?: number;
+ raioUsado?: number;
+ enderecoEncontrado?: string;
+ avisos: string[];
+ } | null = null;
+
+ if (
+ configPonto?.validarLocalizacao !== false &&
+ args.informacoesDispositivo?.latitude &&
+ args.informacoesDispositivo?.longitude
+ ) {
+ const geofencing = await validarLocalizacaoGeofencingInternal(
+ ctx,
+ usuario.funcionarioId,
+ args.informacoesDispositivo.latitude,
+ args.informacoesDispositivo.longitude,
+ configPonto?.toleranciaDistanciaMetros ?? 100
+ );
+
+ validacaoGeofencing = geofencing;
+
+ // Adicionar avisos de geofencing aos avisos de validação
+ if (geofencing.avisos.length > 0) {
+ if (!validacaoLocalizacao) {
+ validacaoLocalizacao = {
+ valida: true,
+ scoreConfianca: 1,
+ avisos: [],
+ };
+ }
+ validacaoLocalizacao.avisos.push(...geofencing.avisos);
+
+ // Reduzir score de confiança se estiver fora do raio
+ if (!geofencing.dentroRaio) {
+ validacaoLocalizacao.scoreConfianca = Math.min(
+ validacaoLocalizacao.scoreConfianca,
+ 0.7
+ );
+ }
+ }
+ }
+
// Criar registro
const registroId = await ctx.db.insert('registrosPonto', {
funcionarioId: usuario.funcionarioId,
@@ -272,6 +607,22 @@ export const registrarPonto = mutation({
latitude: args.informacoesDispositivo?.latitude,
longitude: args.informacoesDispositivo?.longitude,
precisao: args.informacoesDispositivo?.precisao,
+ altitude: args.informacoesDispositivo?.altitude,
+ altitudeAccuracy: args.informacoesDispositivo?.altitudeAccuracy,
+ heading: args.informacoesDispositivo?.heading,
+ speed: args.informacoesDispositivo?.speed,
+ confiabilidadeGPS: args.informacoesDispositivo?.confiabilidadeGPS,
+ scoreConfiancaBackend: validacaoLocalizacao?.scoreConfianca,
+ suspeitaSpoofing: args.informacoesDispositivo?.suspeitaSpoofing || (validacaoLocalizacao ? validacaoLocalizacao.scoreConfianca < 0.5 || !validacaoLocalizacao.valida : undefined),
+ motivoSuspeita: args.informacoesDispositivo?.motivoSuspeita || validacaoLocalizacao?.motivo || (validacaoLocalizacao && validacaoLocalizacao.avisos.length > 0 ? validacaoLocalizacao.avisos.join('; ') : undefined),
+ // Informações detalhadas de validação (sempre salvar quando houver validação)
+ avisosValidacao: validacaoLocalizacao && validacaoLocalizacao.avisos.length > 0 ? validacaoLocalizacao.avisos : undefined,
+ // Informações de Geofencing
+ enderecoMarcacaoEsperado: validacaoGeofencing?.enderecoMaisProximo,
+ distanciaEnderecoEsperado: validacaoGeofencing?.distanciaMetros,
+ dentroRaioPermitido: validacaoGeofencing?.dentroRaio,
+ enderecoMarcacaoUsado: validacaoGeofencing?.enderecoMaisProximo,
+ raioToleranciaUsado: validacaoGeofencing?.raioUsado,
endereco: args.informacoesDispositivo?.endereco,
cidade: args.informacoesDispositivo?.cidade,
estado: args.informacoesDispositivo?.estado,
diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts
index 1d301ab..f891d83 100644
--- a/packages/backend/convex/schema.ts
+++ b/packages/backend/convex/schema.ts
@@ -1367,6 +1367,25 @@ export default defineSchema({
latitude: v.optional(v.number()),
longitude: v.optional(v.number()),
precisao: v.optional(v.number()),
+ altitude: v.optional(v.union(v.number(), v.null())),
+ altitudeAccuracy: v.optional(v.union(v.number(), v.null())),
+ heading: v.optional(v.union(v.number(), v.null())),
+ speed: v.optional(v.union(v.number(), v.null())),
+ confiabilidadeGPS: v.optional(v.number()), // 0-1 (frontend)
+ scoreConfiancaBackend: v.optional(v.number()), // 0-1 (backend)
+ suspeitaSpoofing: v.optional(v.boolean()),
+ motivoSuspeita: v.optional(v.string()),
+ avisosValidacao: v.optional(v.array(v.string())), // Array de avisos detalhados da validação
+ distanciaIPvsGPS: v.optional(v.number()), // Distância em metros entre IP geolocation e GPS
+ velocidadeUltimoRegistro: v.optional(v.number()), // Velocidade em km/h do último registro
+ distanciaUltimoRegistro: v.optional(v.number()), // Distância em metros do último registro
+ tempoDecorridoHoras: v.optional(v.number()), // Tempo em horas desde o último registro
+ // Informações de Geofencing
+ enderecoMarcacaoEsperado: v.optional(v.id("enderecosMarcacao")), // Endereço mais próximo esperado
+ distanciaEnderecoEsperado: v.optional(v.number()), // Distância em metros do endereço esperado
+ dentroRaioPermitido: v.optional(v.boolean()), // Se está dentro do raio permitido
+ enderecoMarcacaoUsado: v.optional(v.id("enderecosMarcacao")), // Qual endereço foi usado na validação
+ raioToleranciaUsado: v.optional(v.number()), // Raio usado na validação em metros
endereco: v.optional(v.string()),
cidade: v.optional(v.string()),
estado: v.optional(v.string()),
@@ -1401,6 +1420,60 @@ export default defineSchema({
.index("by_dentro_prazo", ["dentroDoPrazo", "data"])
.index("by_funcionario_timestamp", ["funcionarioId", "timestamp"]),
+ // Endereços de Marcação - Locais permitidos para registro de ponto
+ enderecosMarcacao: defineTable({
+ nome: v.string(), // Ex: "Sede Principal", "Home Office João Silva", "Cliente ABC"
+ descricao: v.optional(v.string()), // Descrição opcional
+ // Coordenadas (obrigatórias)
+ latitude: v.number(),
+ longitude: v.number(),
+ // Endereço físico (para exibição)
+ endereco: v.string(), // Ex: "Rua Exemplo, 123"
+ bairro: v.optional(v.string()), // Bairro do endereço
+ cep: v.optional(v.string()),
+ cidade: v.string(),
+ estado: v.string(),
+ pais: v.optional(v.string()), // Padrão: "Brasil"
+ // Configurações
+ raioMetros: v.number(), // Raio de tolerância em metros (ex: 100m, 500m, 1000m)
+ ativo: v.boolean(),
+ // Tipos de uso
+ tipo: v.union(
+ v.literal("sede"), // Sede principal (para todos)
+ v.literal("home_office"), // Home office específico
+ v.literal("deslocamento"), // Deslocamento temporário
+ v.literal("cliente") // Local de cliente
+ ),
+ // Metadados
+ criadoPor: v.id("usuarios"),
+ criadoEm: v.number(),
+ atualizadoPor: v.optional(v.id("usuarios")),
+ atualizadoEm: v.optional(v.number()),
+ })
+ .index("by_ativo", ["ativo"])
+ .index("by_tipo", ["tipo"])
+ .index("by_cidade", ["cidade"]),
+
+ // Associação Funcionário ↔ Endereço de Marcação
+ funcionarioEnderecosMarcacao: defineTable({
+ funcionarioId: v.id("funcionarios"),
+ enderecoMarcacaoId: v.id("enderecosMarcacao"),
+ // Configurações específicas do funcionário
+ raioMetrosPersonalizado: v.optional(v.number()), // Pode ter raio diferente do padrão
+ // Período de validade (para deslocamentos temporários)
+ dataInicio: v.optional(v.string()), // YYYY-MM-DD
+ dataFim: v.optional(v.string()), // YYYY-MM-DD
+ // Status
+ ativo: v.boolean(),
+ // Metadados
+ criadoPor: v.id("usuarios"),
+ criadoEm: v.number(),
+ })
+ .index("by_funcionario", ["funcionarioId"])
+ .index("by_endereco", ["enderecoMarcacaoId"])
+ .index("by_funcionario_ativo", ["funcionarioId", "ativo"])
+ .index("by_endereco_ativo", ["enderecoMarcacaoId", "ativo"]),
+
configuracaoPonto: defineTable({
horarioEntrada: v.string(), // HH:mm
horarioSaidaAlmoco: v.string(), // HH:mm
@@ -1414,6 +1487,9 @@ export default defineSchema({
nomeSaida: v.optional(v.string()), // Padrão: "Saída 2"
// Ajuste de fuso horário (GMT offset em horas)
gmtOffset: v.optional(v.number()), // Padrão: 0 (UTC)
+ // Configurações de geofencing
+ validarLocalizacao: v.optional(v.boolean()), // Habilitar/desabilitar validação de localização
+ toleranciaDistanciaMetros: v.optional(v.number()), // Raio padrão global em metros
ativo: v.boolean(),
atualizadoPor: v.id("usuarios"),
atualizadoEm: v.number(),