feat: enhance point registration and location validation features
- Refactored the RegistroPonto component to improve the layout and user experience, including a new section for displaying standard hours. - Updated RelogioSincronizado to include GMT offset adjustments for accurate time display. - Introduced new location validation logic in the backend to ensure point registrations are within allowed geofenced areas. - Enhanced the device information schema to capture additional GPS data, improving the reliability of location checks. - Added new endpoints for managing allowed marking addresses, facilitating better control over where points can be registered.
This commit is contained in:
@@ -65,9 +65,9 @@
|
||||
$: needsScroll = filtered.length > 8;
|
||||
</script>
|
||||
|
||||
<main class="container mx-auto px-4 py-4">
|
||||
<main class="container mx-auto px-4 py-4 max-w-7xl flex flex-col" style="height: calc(100vh - 8rem); min-height: 600px;">
|
||||
<!-- Breadcrumb -->
|
||||
<div class="breadcrumbs mb-4 text-sm">
|
||||
<div class="breadcrumbs mb-4 text-sm flex-shrink-0">
|
||||
<ul>
|
||||
<li><a href={resolve('/recursos-humanos')} class="text-primary hover:underline">Recursos Humanos</a></li>
|
||||
<li>Funcionários</li>
|
||||
@@ -75,7 +75,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Cabeçalho -->
|
||||
<div class="mb-6">
|
||||
<div class="mb-6 flex-shrink-0">
|
||||
<div class="flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="rounded-xl bg-blue-500/20 p-3">
|
||||
@@ -99,7 +99,7 @@
|
||||
<p class="text-base-content/70">Gerencie os funcionários da secretaria</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-lg gap-2" onclick={navCadastro}>
|
||||
<button class="btn btn-primary btn-lg gap-2 shadow-md hover:shadow-lg transition-all" onclick={navCadastro}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
@@ -118,7 +118,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Filtros -->
|
||||
<div class="card bg-base-100 mb-6 shadow-xl">
|
||||
<div class="card bg-base-100/90 backdrop-blur-sm border border-base-300 mb-4 shadow-xl flex-shrink-0">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title mb-4 text-lg">
|
||||
<svg
|
||||
@@ -223,82 +223,133 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabela de Funcionários -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body p-0">
|
||||
<div class="overflow-x-auto">
|
||||
<div class="overflow-y-auto" style="max-height: {filtered.length > 8 ? '600px' : 'none'};">
|
||||
<table class="table-zebra table w-full">
|
||||
<thead class="bg-base-200 sticky top-0 z-10">
|
||||
<tr>
|
||||
<th class="font-bold">Nome</th>
|
||||
<th class="font-bold">CPF</th>
|
||||
<th class="font-bold">Matrícula</th>
|
||||
<th class="font-bold">Tipo</th>
|
||||
<th class="font-bold">Cidade</th>
|
||||
<th class="font-bold">UF</th>
|
||||
<th class="text-right font-bold">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each filtered as f}
|
||||
<tr class="hover">
|
||||
<td class="font-medium">{f.nome}</td>
|
||||
<td>{f.cpf}</td>
|
||||
<td>{f.matricula}</td>
|
||||
<td>{f.simboloTipo}</td>
|
||||
<td>{f.cidade}</td>
|
||||
<td>{f.uf}</td>
|
||||
<td class="text-right">
|
||||
<div class="dropdown dropdown-end" class:dropdown-open={openMenuId === f._id}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Abrir menu"
|
||||
class="btn btn-sm"
|
||||
onclick={() => toggleMenu(f._id)}
|
||||
>
|
||||
<!-- Container da Tabela com altura responsiva -->
|
||||
<div class="flex-1 flex flex-col min-h-0">
|
||||
<!-- Tabela de Funcionários -->
|
||||
<div class="card bg-base-100/90 backdrop-blur-sm border border-base-300 shadow-xl flex-1 flex flex-col min-h-0">
|
||||
<div class="card-body p-0 flex-1 flex flex-col min-h-0">
|
||||
<!-- Container com scroll -->
|
||||
<div class="flex-1 overflow-hidden flex flex-col">
|
||||
<div class="overflow-x-auto flex-1 overflow-y-auto">
|
||||
<table class="table table-zebra w-full">
|
||||
<thead class="sticky top-0 z-10 shadow-md bg-gradient-to-r from-base-300 to-base-200">
|
||||
<tr>
|
||||
<th class="whitespace-nowrap font-bold text-base-content border-b border-base-400">Nome</th>
|
||||
<th class="whitespace-nowrap font-bold text-base-content border-b border-base-400">CPF</th>
|
||||
<th class="whitespace-nowrap font-bold text-base-content border-b border-base-400">Matrícula</th>
|
||||
<th class="whitespace-nowrap font-bold text-base-content border-b border-base-400">Tipo</th>
|
||||
<th class="whitespace-nowrap font-bold text-base-content border-b border-base-400">Cidade</th>
|
||||
<th class="whitespace-nowrap font-bold text-base-content border-b border-base-400">UF</th>
|
||||
<th class="text-right whitespace-nowrap font-bold text-base-content border-b border-base-400">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#if filtered.length === 0}
|
||||
<tr>
|
||||
<td colspan="7" class="text-center py-12">
|
||||
<div class="flex flex-col items-center justify-center gap-4">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
><path
|
||||
d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z"
|
||||
/></svg
|
||||
class="h-16 w-16 text-base-content/30"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
</button>
|
||||
<ul
|
||||
class="dropdown-content menu bg-base-100 rounded-box border-base-300 z-10 w-52 border p-2 shadow-lg"
|
||||
>
|
||||
<li>
|
||||
<a href={`/recursos-humanos/funcionarios/${f._id}`}>Ver Detalhes</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href={`/recursos-humanos/funcionarios/${f._id}/editar`}>Editar</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href={`/recursos-humanos/funcionarios/${f._id}/documentos`}
|
||||
>Ver Documentos</a
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
|
||||
/>
|
||||
</svg>
|
||||
<div class="text-base-content/60 text-center">
|
||||
<p class="font-semibold text-lg mb-1">Nenhum funcionário encontrado</p>
|
||||
<p class="text-sm">
|
||||
{#if filtroNome || filtroCPF || filtroMatricula || filtroTipo}
|
||||
Tente ajustar os filtros ou
|
||||
{/if}
|
||||
<button class="btn btn-link btn-sm p-0 h-auto min-h-0" onclick={navCadastro}>
|
||||
cadastre um novo funcionário
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
{#each filtered as f}
|
||||
<tr class="hover:bg-base-200/50 transition-colors">
|
||||
<td class="whitespace-nowrap font-medium">{f.nome}</td>
|
||||
<td class="whitespace-nowrap">{f.cpf}</td>
|
||||
<td class="whitespace-nowrap">{f.matricula}</td>
|
||||
<td class="whitespace-nowrap">
|
||||
<span class="badge badge-outline badge-sm">
|
||||
{f.simboloTipo === 'cargo_comissionado' ? 'Cargo Comissionado' :
|
||||
f.simboloTipo === 'funcao_gratificada' ? 'Função Gratificada' :
|
||||
f.simboloTipo || '-'}
|
||||
</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap">{f.cidade || '-'}</td>
|
||||
<td class="whitespace-nowrap">{f.uf || '-'}</td>
|
||||
<td class="text-right whitespace-nowrap">
|
||||
<div class="dropdown dropdown-end" class:dropdown-open={openMenuId === f._id}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Abrir menu"
|
||||
class="btn btn-sm btn-ghost hover:btn-primary transition-all"
|
||||
onclick={() => toggleMenu(f._id)}
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<button onclick={() => openPrintModal(f._id)}>Imprimir Ficha</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<ul
|
||||
class="dropdown-content menu bg-base-100 rounded-box border-base-300 z-20 w-52 border p-2 shadow-xl"
|
||||
>
|
||||
<li>
|
||||
<a href={`/recursos-humanos/funcionarios/${f._id}`} class="hover:bg-primary/10">
|
||||
Ver Detalhes
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href={`/recursos-humanos/funcionarios/${f._id}/editar`} class="hover:bg-primary/10">
|
||||
Editar
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href={`/recursos-humanos/funcionarios/${f._id}/documentos`} class="hover:bg-primary/10">
|
||||
Ver Documentos
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<button onclick={() => openPrintModal(f._id)} class="hover:bg-primary/10">
|
||||
Imprimir Ficha
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Informação sobre resultados -->
|
||||
<div class="text-base-content/70 mt-4 text-center text-sm">
|
||||
Exibindo {filtered.length} de {list.length} funcionário(s)
|
||||
<!-- Informação sobre resultados -->
|
||||
<div class="text-base-content/70 mt-3 text-center text-sm flex-shrink-0 border-t border-base-300 pt-3 bg-base-100/50">
|
||||
Exibindo <span class="font-semibold">{filtered.length}</span> de <span class="font-semibold">{list.length}</span> funcionário(s)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal de Impressão -->
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
APOSENTADO_OPTIONS,
|
||||
} from "$lib/utils/constants";
|
||||
import PrintModal from "$lib/components/PrintModal.svelte";
|
||||
import { MapPin } from "lucide-svelte";
|
||||
|
||||
const client = useConvexClient();
|
||||
|
||||
@@ -203,6 +204,16 @@
|
||||
</svg>
|
||||
Imprimir Ficha
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-info gap-2"
|
||||
onclick={() =>
|
||||
goto(
|
||||
resolve(`/recursos-humanos/funcionarios/${funcionarioId}/enderecos-marcacao`),
|
||||
)}
|
||||
>
|
||||
<MapPin class="h-5 w-5" />
|
||||
Endereços de Marcação
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { useQuery, useConvexClient } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import { MapPin, Plus, X, Edit, Trash2, Search } from 'lucide-svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
const client = useConvexClient();
|
||||
const funcionarioId = $derived($page.params.funcionarioId as Id<'funcionarios'>);
|
||||
|
||||
// Queries
|
||||
const funcionarioQuery = useQuery(
|
||||
api.funcionarios.getFichaCompleta,
|
||||
funcionarioId ? { id: funcionarioId } : 'skip'
|
||||
);
|
||||
const associacoesQuery = useQuery(
|
||||
api.funcionarioEnderecos.listarAssociacoesFuncionario,
|
||||
funcionarioId ? { funcionarioId, incluirInativos: true } : 'skip'
|
||||
);
|
||||
const enderecosDisponiveisQuery = useQuery(api.enderecosMarcacao.listarEnderecos, {
|
||||
incluirInativos: false,
|
||||
});
|
||||
|
||||
const funcionario = $derived(funcionarioQuery?.data);
|
||||
const associacoes = $derived(associacoesQuery?.data || []);
|
||||
const enderecosDisponiveis = $derived(enderecosDisponiveisQuery?.data || []);
|
||||
|
||||
// Estados
|
||||
let mostrarModalAssociacao = $state(false);
|
||||
let editandoAssociacao: (typeof associacoes)[number] | null = $state(null);
|
||||
let processando = $state(false);
|
||||
let termoBusca = $state('');
|
||||
|
||||
// Campos do formulário
|
||||
let enderecoSelecionado: Id<'enderecosMarcacao'> | '' = $state('');
|
||||
let raioPersonalizado: number | '' = $state('');
|
||||
let dataInicio = $state('');
|
||||
let dataFim = $state('');
|
||||
|
||||
// Filtrar associacoes
|
||||
const associacoesFiltradas = $derived(
|
||||
associacoes.filter((a) => {
|
||||
if (!termoBusca) return true;
|
||||
const busca = termoBusca.toLowerCase();
|
||||
return (
|
||||
a.endereco.nome.toLowerCase().includes(busca) ||
|
||||
a.endereco.endereco.toLowerCase().includes(busca) ||
|
||||
a.endereco.cidade.toLowerCase().includes(busca) ||
|
||||
a.endereco.tipo.toLowerCase().includes(busca)
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
// Endereços já associados (para não mostrar no select)
|
||||
const enderecosJaAssociados = $derived(
|
||||
new Set(associacoes.filter((a) => a.ativo).map((a) => a.endereco._id))
|
||||
);
|
||||
|
||||
// Endereços disponíveis para associar (não associados e ativos)
|
||||
const enderecosParaAssociar = $derived(
|
||||
enderecosDisponiveis.filter((e) => e.ativo && !enderecosJaAssociados.has(e._id))
|
||||
);
|
||||
|
||||
function limparFormulario() {
|
||||
enderecoSelecionado = '';
|
||||
raioPersonalizado = '';
|
||||
dataInicio = '';
|
||||
dataFim = '';
|
||||
editandoAssociacao = null;
|
||||
mostrarModalAssociacao = false;
|
||||
}
|
||||
|
||||
function abrirFormularioEdicao(associacao: (typeof associacoes)[number]) {
|
||||
editandoAssociacao = associacao;
|
||||
enderecoSelecionado = associacao.endereco._id;
|
||||
raioPersonalizado = associacao.raioMetrosPersonalizado ?? '';
|
||||
dataInicio = associacao.dataInicio || '';
|
||||
dataFim = associacao.dataFim || '';
|
||||
mostrarModalAssociacao = true;
|
||||
}
|
||||
|
||||
async function salvarAssociacao() {
|
||||
if (!enderecoSelecionado) {
|
||||
toast.error('Selecione um endereço');
|
||||
return;
|
||||
}
|
||||
|
||||
if (raioPersonalizado !== '' && (raioPersonalizado < 0 || raioPersonalizado > 50000)) {
|
||||
toast.error('Raio personalizado deve estar entre 0 e 50000 metros');
|
||||
return;
|
||||
}
|
||||
|
||||
if (dataInicio && dataFim && dataInicio > dataFim) {
|
||||
toast.error('Data de início deve ser anterior à data de fim');
|
||||
return;
|
||||
}
|
||||
|
||||
processando = true;
|
||||
try {
|
||||
if (editandoAssociacao) {
|
||||
await client.mutation(api.funcionarioEnderecos.atualizarAssociacao, {
|
||||
associacaoId: editandoAssociacao._id,
|
||||
raioMetrosPersonalizado:
|
||||
raioPersonalizado !== '' ? Number(raioPersonalizado) : undefined,
|
||||
dataInicio: dataInicio || undefined,
|
||||
dataFim: dataFim || undefined,
|
||||
});
|
||||
toast.success('Associação atualizada com sucesso!');
|
||||
} else {
|
||||
await client.mutation(api.funcionarioEnderecos.associarEnderecoFuncionario, {
|
||||
funcionarioId,
|
||||
enderecoMarcacaoId: enderecoSelecionado as Id<'enderecosMarcacao'>,
|
||||
raioMetrosPersonalizado:
|
||||
raioPersonalizado !== '' ? Number(raioPersonalizado) : undefined,
|
||||
dataInicio: dataInicio || undefined,
|
||||
dataFim: dataFim || undefined,
|
||||
});
|
||||
toast.success('Endereço associado com sucesso!');
|
||||
}
|
||||
|
||||
limparFormulario();
|
||||
} catch (error) {
|
||||
console.error('Erro ao salvar associação:', error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : 'Erro ao salvar associação. Tente novamente.'
|
||||
);
|
||||
} finally {
|
||||
processando = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removerAssociacao(associacaoId: Id<'funcionarioEnderecosMarcacao'>) {
|
||||
if (!confirm('Tem certeza que deseja remover esta associação?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await client.mutation(api.funcionarioEnderecos.removerAssociacao, { associacaoId });
|
||||
toast.success('Associação removida com sucesso!');
|
||||
} catch (error) {
|
||||
console.error('Erro ao remover associação:', error);
|
||||
toast.error('Erro ao remover associação. Tente novamente.');
|
||||
}
|
||||
}
|
||||
|
||||
// Verificar se funcionário existe
|
||||
onMount(() => {
|
||||
if (!funcionarioId) {
|
||||
goto(resolve('/recursos-humanos/funcionarios'));
|
||||
}
|
||||
});
|
||||
|
||||
const tiposLabel: Record<string, string> = {
|
||||
sede: 'Sede Principal',
|
||||
home_office: 'Home Office',
|
||||
deslocamento: 'Deslocamento',
|
||||
cliente: 'Cliente',
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="container mx-auto px-4 py-6 max-w-7xl">
|
||||
<!-- Breadcrumb -->
|
||||
<div class="text-sm breadcrumbs mb-4">
|
||||
<ul>
|
||||
<li>
|
||||
<a href={resolve('/recursos-humanos')} class="text-primary hover:underline">
|
||||
Recursos Humanos
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href={resolve('/recursos-humanos/funcionarios')}
|
||||
class="text-primary hover:underline"
|
||||
>
|
||||
Funcionários
|
||||
</a>
|
||||
</li>
|
||||
{#if funcionario}
|
||||
<li>
|
||||
<a
|
||||
href={resolve(`/recursos-humanos/funcionarios/${funcionarioId}`)}
|
||||
class="text-primary hover:underline"
|
||||
>
|
||||
{funcionario.nome}
|
||||
</a>
|
||||
</li>
|
||||
{/if}
|
||||
<li>Endereços de Marcação</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="p-3 bg-primary/10 rounded-xl">
|
||||
<MapPin class="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-base-content">Endereços de Marcação</h1>
|
||||
<p class="text-base-content/60 mt-1">
|
||||
{#if funcionario}
|
||||
Gerenciar locais permitidos para registro de ponto de {funcionario.nome}
|
||||
{:else}
|
||||
Carregando...
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{#if enderecosParaAssociar.length > 0}
|
||||
<button
|
||||
class="btn btn-primary gap-2"
|
||||
onclick={() => {
|
||||
limparFormulario();
|
||||
mostrarModalAssociacao = true;
|
||||
}}
|
||||
disabled={mostrarModalAssociacao}
|
||||
>
|
||||
<Plus class="h-5 w-5" />
|
||||
Associar Endereço
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Modal de Associação -->
|
||||
{#if mostrarModalAssociacao}
|
||||
<div class="modal modal-open">
|
||||
<div class="modal-box max-w-2xl">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-2xl font-bold">
|
||||
{editandoAssociacao ? 'Editar Associação' : 'Associar Endereço'}
|
||||
</h2>
|
||||
<button class="btn btn-sm btn-circle btn-ghost" onclick={limparFormulario}>
|
||||
<X class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Endereço -->
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Endereço *</span>
|
||||
</label>
|
||||
<select
|
||||
bind:value={enderecoSelecionado}
|
||||
class="select select-bordered"
|
||||
disabled={!!editandoAssociacao}
|
||||
>
|
||||
<option value="">Selecione um endereço</option>
|
||||
{#if editandoAssociacao}
|
||||
<!-- Ao editar, mostrar apenas o endereço atual -->
|
||||
<option value={editandoAssociacao.endereco._id}>
|
||||
{editandoAssociacao.endereco.nome} - {editandoAssociacao.endereco.endereco}, {editandoAssociacao.endereco.cidade}/{editandoAssociacao.endereco.estado}
|
||||
(raio padrão: {editandoAssociacao.endereco.raioMetros}m)
|
||||
</option>
|
||||
{:else}
|
||||
<!-- Ao criar, mostrar apenas endereços disponíveis para associar -->
|
||||
{#each enderecosParaAssociar as endereco (endereco._id)}
|
||||
<option value={endereco._id}>
|
||||
{endereco.nome} - {endereco.endereco}, {endereco.cidade}/{endereco.estado}
|
||||
(raio padrão: {endereco.raioMetros}m)
|
||||
</option>
|
||||
{/each}
|
||||
{/if}
|
||||
</select>
|
||||
<div class="label">
|
||||
<span class="label-text-alt">
|
||||
{#if editandoAssociacao}
|
||||
Não é possível alterar o endereço. Crie uma nova associação se necessário.
|
||||
{:else}
|
||||
Selecione um endereço que ainda não está associado a este funcionário
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Raio Personalizado -->
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Raio Personalizado (metros)</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={raioPersonalizado}
|
||||
min="0"
|
||||
max="50000"
|
||||
placeholder="Deixe vazio para usar o raio padrão do endereço"
|
||||
class="input input-bordered"
|
||||
/>
|
||||
<div class="label">
|
||||
<span class="label-text-alt">
|
||||
Opcional. Se informado, sobrescreve o raio padrão do endereço para este
|
||||
funcionário.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Período de Validade -->
|
||||
<div class="divider">Período de Validade (opcional)</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Data Início</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
bind:value={dataInicio}
|
||||
class="input input-bordered"
|
||||
/>
|
||||
<div class="label">
|
||||
<span class="label-text-alt">Para deslocamentos temporários</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Data Fim</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
bind:value={dataFim}
|
||||
class="input input-bordered"
|
||||
/>
|
||||
<div class="label">
|
||||
<span class="label-text-alt">Para deslocamentos temporários</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button class="btn btn-ghost" onclick={limparFormulario} disabled={processando}>
|
||||
Cancelar
|
||||
</button>
|
||||
<button class="btn btn-primary" onclick={salvarAssociacao} disabled={processando}>
|
||||
{#if processando}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{:else}
|
||||
Salvar
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Barra de Busca -->
|
||||
<div class="card bg-base-100 shadow-xl mb-6">
|
||||
<div class="card-body p-4">
|
||||
<div class="form-control">
|
||||
<div class="relative">
|
||||
<Search
|
||||
class="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-base-content/50"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={termoBusca}
|
||||
placeholder="Buscar por nome, endereço, cidade ou tipo..."
|
||||
class="input input-bordered pl-10 w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Aviso sobre endereços tipo "sede" -->
|
||||
{#if associacoes.length === 0}
|
||||
<div class="alert alert-info mb-6">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
class="stroke-current shrink-0 h-6 w-6"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
></path>
|
||||
</svg>
|
||||
<span>
|
||||
Este funcionário não possui endereços específicos associados. O sistema usará
|
||||
automaticamente os endereços tipo "Sede Principal" configurados globalmente.
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Lista de Associações -->
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
{#if associacoesFiltradas.length === 0}
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body text-center py-12">
|
||||
<MapPin class="h-16 w-16 text-base-content/20 mx-auto mb-4" />
|
||||
<p class="text-lg text-base-content/60">
|
||||
{termoBusca
|
||||
? 'Nenhuma associação encontrada'
|
||||
: 'Nenhum endereço associado'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
{#each associacoesFiltradas as associacao (associacao._id)}
|
||||
{@const raioUsado = associacao.raioMetros}
|
||||
<div
|
||||
class="card bg-base-100 shadow-xl {associacao.ativo ? '' : 'opacity-60'}"
|
||||
>
|
||||
<div class="card-body">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<h3 class="text-xl font-bold">{associacao.endereco.nome}</h3>
|
||||
<span
|
||||
class="badge {associacao.ativo ? 'badge-success' : 'badge-error'}"
|
||||
>
|
||||
{associacao.ativo ? 'Ativa' : 'Inativa'}
|
||||
</span>
|
||||
<span class="badge badge-outline">
|
||||
{tiposLabel[associacao.endereco.tipo] || associacao.endereco.tipo}
|
||||
</span>
|
||||
</div>
|
||||
<div class="space-y-1 text-sm">
|
||||
<p class="text-base-content/80">
|
||||
<strong>Endereço:</strong> {associacao.endereco.endereco}
|
||||
</p>
|
||||
<p class="text-base-content/80">
|
||||
<strong>Cidade:</strong> {associacao.endereco.cidade}/
|
||||
{associacao.endereco.estado}
|
||||
</p>
|
||||
<p class="text-base-content/80">
|
||||
<strong>Raio Permitido:</strong>
|
||||
{#if raioUsado >= 1000}
|
||||
{@const raioKm = (raioUsado / 1000).toFixed(2)}
|
||||
{raioKm} km
|
||||
{:else}
|
||||
{raioUsado} metros
|
||||
{/if}
|
||||
{#if associacao.raioMetrosPersonalizado !== null &&
|
||||
associacao.raioMetrosPersonalizado !== undefined}
|
||||
<span class="badge badge-sm badge-primary ml-2">
|
||||
Personalizado
|
||||
</span>
|
||||
{:else}
|
||||
<span class="badge badge-sm badge-outline ml-2">
|
||||
Padrão ({associacao.endereco.raioMetros}m)
|
||||
</span>
|
||||
{/if}
|
||||
</p>
|
||||
{#if associacao.dataInicio || associacao.dataFim}
|
||||
<p class="text-base-content/80">
|
||||
<strong>Período:</strong>
|
||||
{#if associacao.dataInicio}
|
||||
{@const dataInicioFormatada = new Date(associacao.dataInicio + 'T00:00:00').toLocaleDateString('pt-BR')}
|
||||
{dataInicioFormatada}
|
||||
{:else}
|
||||
...
|
||||
{/if}
|
||||
até
|
||||
{#if associacao.dataFim}
|
||||
{@const dataFimFormatada = new Date(associacao.dataFim + 'T00:00:00').toLocaleDateString('pt-BR')}
|
||||
{dataFimFormatada}
|
||||
{:else}
|
||||
...
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<button
|
||||
class="btn btn-sm btn-outline btn-primary gap-2"
|
||||
onclick={() => abrirFormularioEdicao(associacao)}
|
||||
>
|
||||
<Edit class="h-4 w-4" />
|
||||
Editar
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm btn-outline btn-error gap-2"
|
||||
onclick={() => removerAssociacao(associacao._id)}
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
Remover
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { useQuery, useConvexClient } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import { Clock, Save, CheckCircle2 } from 'lucide-svelte';
|
||||
import { Clock, Save, CheckCircle2, MapPin } from 'lucide-svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
|
||||
const client = useConvexClient();
|
||||
const configQuery = useQuery(api.configuracaoPonto.obterConfiguracao, {});
|
||||
@@ -93,6 +94,13 @@
|
||||
<p class="text-base-content/60 mt-1">Configure os horários de trabalho e tolerâncias</p>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={resolve('/ti/configuracoes-ponto/enderecos')}
|
||||
class="btn btn-secondary gap-2"
|
||||
>
|
||||
<MapPin class="h-5 w-5" />
|
||||
Endereços de Marcação
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Mensagens -->
|
||||
|
||||
@@ -0,0 +1,804 @@
|
||||
<script lang="ts">
|
||||
import { useQuery, useConvexClient } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import { MapPin, Plus, Edit, Power, Search, X, Loader2 } from 'lucide-svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { maskCEP, onlyDigits } from '$lib/utils/masks';
|
||||
|
||||
const client = useConvexClient();
|
||||
const enderecosQuery = useQuery(api.enderecosMarcacao.listarEnderecos, { incluirInativos: true });
|
||||
|
||||
const enderecos = $derived(enderecosQuery?.data || []);
|
||||
|
||||
// Estados do formulário
|
||||
let mostrarFormulario = $state(false);
|
||||
let editandoId: Id<'enderecosMarcacao'> | null = $state(null);
|
||||
let processando = $state(false);
|
||||
let termoBusca = $state('');
|
||||
let buscandoCEP = $state(false);
|
||||
let buscandoCoordenadas = $state(false);
|
||||
|
||||
// Campos do formulário
|
||||
let nome = $state('');
|
||||
let descricao = $state('');
|
||||
let latitude = $state<number | ''>('');
|
||||
let longitude = $state<number | ''>('');
|
||||
let endereco = $state('');
|
||||
let bairro = $state('');
|
||||
let cep = $state('');
|
||||
let cidade = $state('');
|
||||
let estado = $state('');
|
||||
let pais = $state('Brasil');
|
||||
let raioMetros = $state(100);
|
||||
let tipo: 'sede' | 'home_office' | 'deslocamento' | 'cliente' = $state('sede');
|
||||
|
||||
// Endereços filtrados
|
||||
const enderecosFiltrados = $derived(
|
||||
enderecos.filter((e) => {
|
||||
if (!termoBusca) return true;
|
||||
const busca = termoBusca.toLowerCase();
|
||||
return (
|
||||
e.nome.toLowerCase().includes(busca) ||
|
||||
e.endereco.toLowerCase().includes(busca) ||
|
||||
e.cidade.toLowerCase().includes(busca) ||
|
||||
e.estado.toLowerCase().includes(busca) ||
|
||||
e.tipo.toLowerCase().includes(busca)
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
// Função para buscar endereço por CEP usando ViaCEP
|
||||
async function buscarEnderecoPorCEP() {
|
||||
const cepLimpo = onlyDigits(cep);
|
||||
|
||||
if (cepLimpo.length !== 8) {
|
||||
return;
|
||||
}
|
||||
|
||||
buscandoCEP = true;
|
||||
try {
|
||||
const response = await fetch(`https://viacep.com.br/ws/${cepLimpo}/json/`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.erro) {
|
||||
toast.error('CEP não encontrado');
|
||||
return;
|
||||
}
|
||||
|
||||
// Preencher campos automaticamente
|
||||
endereco = data.logradouro || '';
|
||||
bairro = data.bairro || '';
|
||||
cidade = data.localidade || '';
|
||||
estado = data.uf || '';
|
||||
|
||||
// Formatar CEP com máscara
|
||||
cep = maskCEP(cepLimpo);
|
||||
|
||||
// Se tiver endereço, tentar buscar coordenadas
|
||||
if (endereco && cidade && estado) {
|
||||
await buscarCoordenadasPorEndereco();
|
||||
} else {
|
||||
toast.success('Endereço encontrado! Preencha o número do endereço e busque as coordenadas.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao buscar CEP:', error);
|
||||
toast.error('Erro ao buscar CEP. Tente novamente.');
|
||||
} finally {
|
||||
buscandoCEP = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Função para buscar coordenadas por endereço usando Google Maps Geocoding API
|
||||
async function buscarCoordenadasPorEndereco() {
|
||||
if (!endereco.trim() || !cidade.trim() || !estado.trim()) {
|
||||
toast.warning('Preencha o endereço, cidade e estado antes de buscar coordenadas');
|
||||
return;
|
||||
}
|
||||
|
||||
buscandoCoordenadas = true;
|
||||
try {
|
||||
// Construir endereço completo para busca
|
||||
let enderecoCompleto = endereco.trim();
|
||||
if (bairro.trim()) {
|
||||
enderecoCompleto += `, ${bairro.trim()}`;
|
||||
}
|
||||
enderecoCompleto += `, ${cidade.trim()}, ${estado.trim()}, Brasil`;
|
||||
|
||||
// Tentar primeiro com Google Maps Geocoding API (se API key estiver configurada)
|
||||
const googleApiKey = import.meta.env.VITE_GOOGLE_MAPS_API_KEY;
|
||||
|
||||
if (googleApiKey) {
|
||||
try {
|
||||
const googleUrl = `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(enderecoCompleto)}&key=${googleApiKey}&language=pt-BR®ion=br`;
|
||||
|
||||
const googleResponse = await fetch(googleUrl);
|
||||
const googleData = await googleResponse.json();
|
||||
|
||||
if (googleData.status === 'OK' && googleData.results && googleData.results.length > 0) {
|
||||
const resultado = googleData.results[0];
|
||||
const location = resultado.geometry.location;
|
||||
|
||||
latitude = parseFloat(location.lat.toFixed(6));
|
||||
longitude = parseFloat(location.lng.toFixed(6));
|
||||
|
||||
toast.success('Coordenadas encontradas via Google Maps!');
|
||||
return;
|
||||
}
|
||||
} catch (googleError) {
|
||||
console.warn('Erro ao buscar no Google Maps, tentando OpenStreetMap...', googleError);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback para Nominatim (OpenStreetMap) se Google Maps falhar ou não tiver API key
|
||||
const nominatimUrl = `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(enderecoCompleto)}&limit=1&addressdetails=1`;
|
||||
|
||||
const response = await fetch(nominatimUrl, {
|
||||
headers: {
|
||||
'User-Agent': 'SGSE-App/1.0'
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data && data.length > 0) {
|
||||
const resultado = data[0];
|
||||
latitude = parseFloat(parseFloat(resultado.lat).toFixed(6));
|
||||
longitude = parseFloat(parseFloat(resultado.lon).toFixed(6));
|
||||
|
||||
toast.success('Coordenadas encontradas via OpenStreetMap!');
|
||||
} else {
|
||||
toast.warning('Coordenadas não encontradas. Preencha manualmente.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao buscar coordenadas:', error);
|
||||
toast.error('Erro ao buscar coordenadas. Preencha manualmente.');
|
||||
} finally {
|
||||
buscandoCoordenadas = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Handler para CEP com máscara e busca automática
|
||||
function handleCEPInput(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const cepLimpo = onlyDigits(target.value);
|
||||
|
||||
// Aplicar máscara
|
||||
cep = maskCEP(cepLimpo);
|
||||
|
||||
// Buscar endereço quando CEP estiver completo
|
||||
if (cepLimpo.length === 8) {
|
||||
setTimeout(() => buscarEnderecoPorCEP(), 500); // Delay para evitar múltiplas requisições
|
||||
}
|
||||
}
|
||||
|
||||
// Handler para buscar coordenadas quando endereço mudar
|
||||
function handleEnderecoChange() {
|
||||
// Buscar coordenadas automaticamente quando todos os campos estiverem preenchidos
|
||||
if (endereco.trim() && cidade.trim() && estado.trim() && !latitude && !longitude) {
|
||||
// Não buscar automaticamente, apenas quando o usuário clicar em buscar
|
||||
}
|
||||
}
|
||||
|
||||
function limparFormulario() {
|
||||
nome = '';
|
||||
descricao = '';
|
||||
latitude = '';
|
||||
longitude = '';
|
||||
endereco = '';
|
||||
bairro = '';
|
||||
cep = '';
|
||||
cidade = '';
|
||||
estado = '';
|
||||
pais = 'Brasil';
|
||||
raioMetros = 100;
|
||||
tipo = 'sede';
|
||||
editandoId = null;
|
||||
mostrarFormulario = false;
|
||||
buscandoCEP = false;
|
||||
buscandoCoordenadas = false;
|
||||
}
|
||||
|
||||
function abrirFormularioEdicao(enderecoParaEditar: typeof enderecos[number]) {
|
||||
editandoId = enderecoParaEditar._id;
|
||||
nome = enderecoParaEditar.nome;
|
||||
descricao = enderecoParaEditar.descricao || '';
|
||||
latitude = enderecoParaEditar.latitude;
|
||||
longitude = enderecoParaEditar.longitude;
|
||||
endereco = enderecoParaEditar.endereco;
|
||||
bairro = (enderecoParaEditar as any).bairro || '';
|
||||
cep = enderecoParaEditar.cep ? maskCEP(enderecoParaEditar.cep) : '';
|
||||
cidade = enderecoParaEditar.cidade;
|
||||
estado = enderecoParaEditar.estado;
|
||||
pais = enderecoParaEditar.pais || 'Brasil';
|
||||
raioMetros = enderecoParaEditar.raioMetros;
|
||||
tipo = enderecoParaEditar.tipo;
|
||||
buscandoCEP = false;
|
||||
buscandoCoordenadas = false;
|
||||
mostrarFormulario = true;
|
||||
}
|
||||
|
||||
async function salvarEndereco() {
|
||||
if (!nome.trim()) {
|
||||
toast.error('Nome é obrigatório');
|
||||
return;
|
||||
}
|
||||
|
||||
if (latitude === '' || longitude === '' || typeof latitude !== 'number' || typeof longitude !== 'number') {
|
||||
toast.error('Latitude e longitude são obrigatórias');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!endereco.trim()) {
|
||||
toast.error('Endereço é obrigatório');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cidade.trim()) {
|
||||
toast.error('Cidade é obrigatória');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!estado.trim()) {
|
||||
toast.error('Estado é obrigatório');
|
||||
return;
|
||||
}
|
||||
|
||||
if (raioMetros < 0 || raioMetros > 50000) {
|
||||
toast.error('Raio deve estar entre 0 e 50000 metros');
|
||||
return;
|
||||
}
|
||||
|
||||
processando = true;
|
||||
try {
|
||||
if (editandoId) {
|
||||
await client.mutation(api.enderecosMarcacao.atualizarEndereco, {
|
||||
enderecoId: editandoId,
|
||||
nome: nome.trim(),
|
||||
descricao: descricao.trim() || undefined,
|
||||
latitude: typeof latitude === 'number' ? latitude : Number(latitude),
|
||||
longitude: typeof longitude === 'number' ? longitude : Number(longitude),
|
||||
endereco: endereco.trim(),
|
||||
bairro: bairro.trim() || undefined,
|
||||
cep: cep.trim() ? onlyDigits(cep.trim()) : undefined,
|
||||
cidade: cidade.trim(),
|
||||
estado: estado.trim(),
|
||||
pais: pais.trim() || undefined,
|
||||
raioMetros,
|
||||
tipo,
|
||||
});
|
||||
toast.success('Endereço atualizado com sucesso!');
|
||||
} else {
|
||||
await client.mutation(api.enderecosMarcacao.criarEndereco, {
|
||||
nome: nome.trim(),
|
||||
descricao: descricao.trim() || undefined,
|
||||
latitude: typeof latitude === 'number' ? latitude : Number(latitude),
|
||||
longitude: typeof longitude === 'number' ? longitude : Number(longitude),
|
||||
endereco: endereco.trim(),
|
||||
bairro: bairro.trim() || undefined,
|
||||
cep: cep.trim() ? onlyDigits(cep.trim()) : undefined,
|
||||
cidade: cidade.trim(),
|
||||
estado: estado.trim(),
|
||||
pais: pais.trim() || undefined,
|
||||
raioMetros,
|
||||
tipo,
|
||||
});
|
||||
toast.success('Endereço criado com sucesso!');
|
||||
}
|
||||
|
||||
limparFormulario();
|
||||
} catch (error) {
|
||||
console.error('Erro ao salvar endereço:', error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : 'Erro ao salvar endereço. Tente novamente.'
|
||||
);
|
||||
} finally {
|
||||
processando = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function desativarEndereco(enderecoId: Id<'enderecosMarcacao'>) {
|
||||
if (!confirm('Tem certeza que deseja desativar este endereço?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await client.mutation(api.enderecosMarcacao.desativarEndereco, { enderecoId });
|
||||
toast.success('Endereço desativado com sucesso!');
|
||||
} catch (error) {
|
||||
console.error('Erro ao desativar endereço:', error);
|
||||
toast.error('Erro ao desativar endereço. Tente novamente.');
|
||||
}
|
||||
}
|
||||
|
||||
async function ativarEndereco(enderecoId: Id<'enderecosMarcacao'>) {
|
||||
try {
|
||||
await client.mutation(api.enderecosMarcacao.atualizarEndereco, {
|
||||
enderecoId,
|
||||
ativo: true,
|
||||
});
|
||||
toast.success('Endereço ativado com sucesso!');
|
||||
} catch (error) {
|
||||
console.error('Erro ao ativar endereço:', error);
|
||||
toast.error('Erro ao ativar endereço. Tente novamente.');
|
||||
}
|
||||
}
|
||||
|
||||
const tiposLabel: Record<'sede' | 'home_office' | 'deslocamento' | 'cliente', string> = {
|
||||
sede: 'Sede Principal',
|
||||
home_office: 'Home Office',
|
||||
deslocamento: 'Deslocamento',
|
||||
cliente: 'Cliente',
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="container mx-auto px-4 py-6 max-w-7xl">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="p-3 bg-primary/10 rounded-xl">
|
||||
<MapPin class="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-base-content">Endereços de Marcação</h1>
|
||||
<p class="text-base-content/60 mt-1">
|
||||
Gerenciar locais permitidos para registro de ponto
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-primary gap-2"
|
||||
onclick={() => {
|
||||
limparFormulario();
|
||||
mostrarFormulario = true;
|
||||
}}
|
||||
disabled={mostrarFormulario}
|
||||
>
|
||||
<Plus class="h-5 w-5" />
|
||||
Novo Endereço
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Formulário Modal -->
|
||||
{#if mostrarFormulario}
|
||||
<div class="modal modal-open">
|
||||
<div class="modal-box max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<!-- Header do Modal -->
|
||||
<div class="flex items-center justify-between mb-6 pb-4 border-b border-base-300">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 bg-primary/10 rounded-lg">
|
||||
<MapPin class="h-6 w-6 text-primary" strokeWidth={2.5} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-base-content">
|
||||
{editandoId ? 'Editar Endereço' : 'Novo Endereço'}
|
||||
</h2>
|
||||
<p class="text-sm text-base-content/60 mt-1">
|
||||
{editandoId ? 'Atualize as informações do endereço' : 'Preencha os dados do novo endereço de marcação'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-circle btn-ghost hover:btn-error transition-all" onclick={limparFormulario}>
|
||||
<X class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Seção 1: Informações Básicas -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<div class="h-px flex-1 bg-gradient-to-r from-transparent via-base-300 to-transparent"></div>
|
||||
<h3 class="text-lg font-semibold text-base-content flex items-center gap-2">
|
||||
<span class="badge badge-primary badge-sm">1</span>
|
||||
Informações Básicas
|
||||
</h3>
|
||||
<div class="h-px flex-1 bg-gradient-to-r from-transparent via-base-300 to-transparent"></div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Nome *</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={nome}
|
||||
placeholder="Ex: Sede Principal, Home Office João"
|
||||
class="input input-bordered input-primary focus:input-primary focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Tipo *</span>
|
||||
</label>
|
||||
<select bind:value={tipo} class="select select-bordered select-primary focus:select-primary focus:ring-2 focus:ring-primary/20">
|
||||
<option value="sede">🏢 Sede Principal</option>
|
||||
<option value="home_office">🏠 Home Office</option>
|
||||
<option value="deslocamento">🚗 Deslocamento</option>
|
||||
<option value="cliente">👥 Cliente</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Descrição</span>
|
||||
<span class="label-text-alt text-base-content/50">(Opcional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
bind:value={descricao}
|
||||
placeholder="Descrição detalhada do endereço ou observações importantes..."
|
||||
class="textarea textarea-bordered textarea-primary focus:textarea-primary focus:ring-2 focus:ring-primary/20"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Seção 2: Endereço Físico -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<div class="h-px flex-1 bg-gradient-to-r from-transparent via-base-300 to-transparent"></div>
|
||||
<h3 class="text-lg font-semibold text-base-content flex items-center gap-2">
|
||||
<span class="badge badge-primary badge-sm">2</span>
|
||||
Endereço Físico
|
||||
</h3>
|
||||
<div class="h-px flex-1 bg-gradient-to-r from-transparent via-base-300 to-transparent"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Endereço *</span>
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={endereco}
|
||||
oninput={handleEnderecoChange}
|
||||
placeholder="Ex: Rua Deputado Cunha Rabelo, 214"
|
||||
class="input input-bordered input-primary focus:input-primary focus:ring-2 focus:ring-primary/20 flex-1"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary gap-2"
|
||||
onclick={buscarCoordenadasPorEndereco}
|
||||
disabled={buscandoCoordenadas || !endereco.trim() || !cidade.trim() || !estado.trim()}
|
||||
title="Buscar coordenadas GPS automaticamente"
|
||||
>
|
||||
{#if buscandoCoordenadas}
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
{:else}
|
||||
<MapPin class="h-4 w-4" />
|
||||
{/if}
|
||||
<span class="hidden sm:inline">Buscar GPS</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Bairro</span>
|
||||
<span class="label-text-alt text-base-content/50">(Opcional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={bairro}
|
||||
placeholder="Ex: Boa Viagem, Centro, Pina"
|
||||
class="input input-bordered focus:input-primary focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">CEP</span>
|
||||
<span class="label-text-alt text-base-content/50">(Opcional - Busca automática)</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={cep}
|
||||
oninput={handleCEPInput}
|
||||
onblur={() => {
|
||||
const cepLimpo = onlyDigits(cep);
|
||||
if (cepLimpo.length === 8) {
|
||||
buscarEnderecoPorCEP();
|
||||
}
|
||||
}}
|
||||
placeholder="00000-000"
|
||||
maxlength="9"
|
||||
class="input input-bordered focus:input-primary focus:ring-2 focus:ring-primary/20 w-full pr-10"
|
||||
/>
|
||||
{#if buscandoCEP}
|
||||
<div class="absolute right-3 top-1/2 transform -translate-y-1/2">
|
||||
<Loader2 class="h-5 w-5 animate-spin text-primary" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-info">
|
||||
{#if buscandoCEP}
|
||||
Buscando endereço...
|
||||
{:else if cep}
|
||||
Digite o CEP completo para buscar automaticamente
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Cidade *</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={cidade}
|
||||
placeholder="Ex: Recife"
|
||||
class="input input-bordered input-primary focus:input-primary focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Estado *</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={estado}
|
||||
placeholder="Ex: PE"
|
||||
maxlength="2"
|
||||
class="input input-bordered input-primary focus:input-primary focus:ring-2 focus:ring-primary/20 uppercase"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">País</span>
|
||||
<span class="label-text-alt text-base-content/50">(Opcional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={pais}
|
||||
placeholder="Brasil"
|
||||
class="input input-bordered focus:input-primary focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Seção 3: Localização GPS e Configuração -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<div class="h-px flex-1 bg-gradient-to-r from-transparent via-base-300 to-transparent"></div>
|
||||
<h3 class="text-lg font-semibold text-base-content flex items-center gap-2">
|
||||
<span class="badge badge-primary badge-sm">3</span>
|
||||
Localização GPS e Configuração
|
||||
</h3>
|
||||
<div class="h-px flex-1 bg-gradient-to-r from-transparent via-base-300 to-transparent"></div>
|
||||
</div>
|
||||
|
||||
<div class="bg-base-200/50 rounded-xl p-4 border border-base-300 mb-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="p-2 bg-info/20 rounded-lg mt-0.5">
|
||||
<MapPin class="h-5 w-5 text-info" strokeWidth={2} />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-semibold text-base-content mb-1">Coordenadas GPS</p>
|
||||
<p class="text-xs text-base-content/70">
|
||||
As coordenadas são usadas para validar a localização dos registros de ponto. O sistema busca automaticamente via Google Maps (se configurado) ou OpenStreetMap.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Latitude *</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="number"
|
||||
step="0.000001"
|
||||
bind:value={latitude}
|
||||
placeholder="-8.047600"
|
||||
class="input input-bordered input-primary focus:input-primary focus:ring-2 focus:ring-primary/20 w-full"
|
||||
/>
|
||||
{#if buscandoCoordenadas}
|
||||
<div class="absolute right-3 top-1/2 transform -translate-y-1/2">
|
||||
<Loader2 class="h-4 w-4 animate-spin text-primary" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-base-content/50">Formato: -8.047600</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Longitude *</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="number"
|
||||
step="0.000001"
|
||||
bind:value={longitude}
|
||||
placeholder="-34.877000"
|
||||
class="input input-bordered input-primary focus:input-primary focus:ring-2 focus:ring-primary/20 w-full"
|
||||
/>
|
||||
{#if buscandoCoordenadas}
|
||||
<div class="absolute right-3 top-1/2 transform -translate-y-1/2">
|
||||
<Loader2 class="h-4 w-4 animate-spin text-primary" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-base-content/50">Formato: -34.877000</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Raio Permitido *</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={raioMetros}
|
||||
min="0"
|
||||
max="50000"
|
||||
placeholder="100"
|
||||
class="input input-bordered input-primary focus:input-primary focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-base-content/50">
|
||||
Em metros. Ex: 100, 500, 1000
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer do Modal -->
|
||||
<div class="modal-action mt-6 pt-4 border-t border-base-300">
|
||||
<button
|
||||
class="btn btn-ghost hover:btn-error transition-all"
|
||||
onclick={limparFormulario}
|
||||
disabled={processando}
|
||||
>
|
||||
<X class="h-4 w-4 mr-2" />
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary gap-2 shadow-md hover:shadow-lg transition-all"
|
||||
onclick={salvarEndereco}
|
||||
disabled={processando}
|
||||
>
|
||||
{#if processando}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
Salvando...
|
||||
{:else}
|
||||
<MapPin class="h-4 w-4" />
|
||||
{editandoId ? 'Atualizar Endereço' : 'Criar Endereço'}
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Barra de Busca -->
|
||||
<div class="card bg-base-100 shadow-xl mb-6">
|
||||
<div class="card-body p-4">
|
||||
<div class="form-control">
|
||||
<div class="relative">
|
||||
<Search class="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-base-content/50" />
|
||||
<input
|
||||
type="text"
|
||||
bind:value={termoBusca}
|
||||
placeholder="Buscar por nome, endereço, cidade, estado ou tipo..."
|
||||
class="input input-bordered pl-10 w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lista de Endereços -->
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
{#if enderecosFiltrados.length === 0}
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body text-center py-12">
|
||||
<MapPin class="h-16 w-16 text-base-content/20 mx-auto mb-4" />
|
||||
<p class="text-lg text-base-content/60">
|
||||
{termoBusca ? 'Nenhum endereço encontrado' : 'Nenhum endereço cadastrado'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
{#each enderecosFiltrados as enderecoItem (enderecoItem._id)}
|
||||
<div
|
||||
class="card bg-base-100 shadow-xl {enderecoItem.ativo ? '' : 'opacity-60'}"
|
||||
>
|
||||
<div class="card-body">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<h3 class="text-xl font-bold">{enderecoItem.nome}</h3>
|
||||
<span
|
||||
class="badge {enderecoItem.ativo ? 'badge-success' : 'badge-error'}"
|
||||
>
|
||||
{enderecoItem.ativo ? 'Ativo' : 'Inativo'}
|
||||
</span>
|
||||
<span class="badge badge-outline">
|
||||
{tiposLabel[enderecoItem.tipo]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if enderecoItem.descricao}
|
||||
<p class="text-base-content/70 mb-2">{enderecoItem.descricao}</p>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-1 text-sm">
|
||||
<p class="text-base-content/80">
|
||||
<strong>Endereço:</strong> {enderecoItem.endereco}
|
||||
{#if (enderecoItem as any).bairro}
|
||||
- <strong>Bairro:</strong> {(enderecoItem as any).bairro}
|
||||
{/if}
|
||||
</p>
|
||||
<p class="text-base-content/80">
|
||||
<strong>Cidade:</strong> {enderecoItem.cidade}/{enderecoItem.estado}
|
||||
{#if enderecoItem.cep}
|
||||
- CEP: {enderecoItem.cep}
|
||||
{/if}
|
||||
</p>
|
||||
<p class="text-base-content/80">
|
||||
<strong>Coordenadas:</strong> {enderecoItem.latitude.toFixed(6)}, {enderecoItem.longitude.toFixed(6)}
|
||||
</p>
|
||||
<p class="text-base-content/80">
|
||||
<strong>Raio Permitido:</strong>
|
||||
{#if enderecoItem.raioMetros >= 1000}
|
||||
{@const raioKm = (enderecoItem.raioMetros / 1000).toFixed(2)}
|
||||
<span> {raioKm} km</span>
|
||||
{:else}
|
||||
<span> {enderecoItem.raioMetros} metros</span>
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<button
|
||||
class="btn btn-sm btn-outline btn-primary gap-2"
|
||||
onclick={() => abrirFormularioEdicao(enderecoItem)}
|
||||
>
|
||||
<Edit class="h-4 w-4" />
|
||||
Editar
|
||||
</button>
|
||||
{#if enderecoItem.ativo}
|
||||
<button
|
||||
class="btn btn-sm btn-outline btn-warning gap-2"
|
||||
onclick={() => desativarEndereco(enderecoItem._id)}
|
||||
>
|
||||
<Power class="h-4 w-4" />
|
||||
Desativar
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
class="btn btn-sm btn-outline btn-success gap-2"
|
||||
onclick={() => ativarEndereco(enderecoItem._id)}
|
||||
>
|
||||
<Power class="h-4 w-4" />
|
||||
Ativar
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user