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:
2025-11-21 05:12:27 -03:00
parent 3da364fb02
commit d6aaa15cf4
17 changed files with 4347 additions and 568 deletions

View File

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

View File

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

View File

@@ -0,0 +1,497 @@
<script lang="ts">
import { onMount } from 'svelte';
import { useQuery, useConvexClient } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { MapPin, Plus, X, Edit, Trash2, Search } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
const client = useConvexClient();
const funcionarioId = $derived($page.params.funcionarioId as Id<'funcionarios'>);
// Queries
const funcionarioQuery = useQuery(
api.funcionarios.getFichaCompleta,
funcionarioId ? { id: funcionarioId } : 'skip'
);
const associacoesQuery = useQuery(
api.funcionarioEnderecos.listarAssociacoesFuncionario,
funcionarioId ? { funcionarioId, incluirInativos: true } : 'skip'
);
const enderecosDisponiveisQuery = useQuery(api.enderecosMarcacao.listarEnderecos, {
incluirInativos: false,
});
const funcionario = $derived(funcionarioQuery?.data);
const associacoes = $derived(associacoesQuery?.data || []);
const enderecosDisponiveis = $derived(enderecosDisponiveisQuery?.data || []);
// Estados
let mostrarModalAssociacao = $state(false);
let editandoAssociacao: (typeof associacoes)[number] | null = $state(null);
let processando = $state(false);
let termoBusca = $state('');
// Campos do formulário
let enderecoSelecionado: Id<'enderecosMarcacao'> | '' = $state('');
let raioPersonalizado: number | '' = $state('');
let dataInicio = $state('');
let dataFim = $state('');
// Filtrar associacoes
const associacoesFiltradas = $derived(
associacoes.filter((a) => {
if (!termoBusca) return true;
const busca = termoBusca.toLowerCase();
return (
a.endereco.nome.toLowerCase().includes(busca) ||
a.endereco.endereco.toLowerCase().includes(busca) ||
a.endereco.cidade.toLowerCase().includes(busca) ||
a.endereco.tipo.toLowerCase().includes(busca)
);
})
);
// Endereços já associados (para não mostrar no select)
const enderecosJaAssociados = $derived(
new Set(associacoes.filter((a) => a.ativo).map((a) => a.endereco._id))
);
// Endereços disponíveis para associar (não associados e ativos)
const enderecosParaAssociar = $derived(
enderecosDisponiveis.filter((e) => e.ativo && !enderecosJaAssociados.has(e._id))
);
function limparFormulario() {
enderecoSelecionado = '';
raioPersonalizado = '';
dataInicio = '';
dataFim = '';
editandoAssociacao = null;
mostrarModalAssociacao = false;
}
function abrirFormularioEdicao(associacao: (typeof associacoes)[number]) {
editandoAssociacao = associacao;
enderecoSelecionado = associacao.endereco._id;
raioPersonalizado = associacao.raioMetrosPersonalizado ?? '';
dataInicio = associacao.dataInicio || '';
dataFim = associacao.dataFim || '';
mostrarModalAssociacao = true;
}
async function salvarAssociacao() {
if (!enderecoSelecionado) {
toast.error('Selecione um endereço');
return;
}
if (raioPersonalizado !== '' && (raioPersonalizado < 0 || raioPersonalizado > 50000)) {
toast.error('Raio personalizado deve estar entre 0 e 50000 metros');
return;
}
if (dataInicio && dataFim && dataInicio > dataFim) {
toast.error('Data de início deve ser anterior à data de fim');
return;
}
processando = true;
try {
if (editandoAssociacao) {
await client.mutation(api.funcionarioEnderecos.atualizarAssociacao, {
associacaoId: editandoAssociacao._id,
raioMetrosPersonalizado:
raioPersonalizado !== '' ? Number(raioPersonalizado) : undefined,
dataInicio: dataInicio || undefined,
dataFim: dataFim || undefined,
});
toast.success('Associação atualizada com sucesso!');
} else {
await client.mutation(api.funcionarioEnderecos.associarEnderecoFuncionario, {
funcionarioId,
enderecoMarcacaoId: enderecoSelecionado as Id<'enderecosMarcacao'>,
raioMetrosPersonalizado:
raioPersonalizado !== '' ? Number(raioPersonalizado) : undefined,
dataInicio: dataInicio || undefined,
dataFim: dataFim || undefined,
});
toast.success('Endereço associado com sucesso!');
}
limparFormulario();
} catch (error) {
console.error('Erro ao salvar associação:', error);
toast.error(
error instanceof Error ? error.message : 'Erro ao salvar associação. Tente novamente.'
);
} finally {
processando = false;
}
}
async function removerAssociacao(associacaoId: Id<'funcionarioEnderecosMarcacao'>) {
if (!confirm('Tem certeza que deseja remover esta associação?')) {
return;
}
try {
await client.mutation(api.funcionarioEnderecos.removerAssociacao, { associacaoId });
toast.success('Associação removida com sucesso!');
} catch (error) {
console.error('Erro ao remover associação:', error);
toast.error('Erro ao remover associação. Tente novamente.');
}
}
// Verificar se funcionário existe
onMount(() => {
if (!funcionarioId) {
goto(resolve('/recursos-humanos/funcionarios'));
}
});
const tiposLabel: Record<string, string> = {
sede: 'Sede Principal',
home_office: 'Home Office',
deslocamento: 'Deslocamento',
cliente: 'Cliente',
};
</script>
<div class="container mx-auto px-4 py-6 max-w-7xl">
<!-- Breadcrumb -->
<div class="text-sm breadcrumbs mb-4">
<ul>
<li>
<a href={resolve('/recursos-humanos')} class="text-primary hover:underline">
Recursos Humanos
</a>
</li>
<li>
<a
href={resolve('/recursos-humanos/funcionarios')}
class="text-primary hover:underline"
>
Funcionários
</a>
</li>
{#if funcionario}
<li>
<a
href={resolve(`/recursos-humanos/funcionarios/${funcionarioId}`)}
class="text-primary hover:underline"
>
{funcionario.nome}
</a>
</li>
{/if}
<li>Endereços de Marcação</li>
</ul>
</div>
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-4">
<div class="p-3 bg-primary/10 rounded-xl">
<MapPin class="h-8 w-8 text-primary" />
</div>
<div>
<h1 class="text-3xl font-bold text-base-content">Endereços de Marcação</h1>
<p class="text-base-content/60 mt-1">
{#if funcionario}
Gerenciar locais permitidos para registro de ponto de {funcionario.nome}
{:else}
Carregando...
{/if}
</p>
</div>
</div>
{#if enderecosParaAssociar.length > 0}
<button
class="btn btn-primary gap-2"
onclick={() => {
limparFormulario();
mostrarModalAssociacao = true;
}}
disabled={mostrarModalAssociacao}
>
<Plus class="h-5 w-5" />
Associar Endereço
</button>
{/if}
</div>
<!-- Modal de Associação -->
{#if mostrarModalAssociacao}
<div class="modal modal-open">
<div class="modal-box max-w-2xl">
<div class="flex items-center justify-between mb-4">
<h2 class="text-2xl font-bold">
{editandoAssociacao ? 'Editar Associação' : 'Associar Endereço'}
</h2>
<button class="btn btn-sm btn-circle btn-ghost" onclick={limparFormulario}>
<X class="h-5 w-5" />
</button>
</div>
<div class="space-y-4">
<!-- Endereço -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Endereço *</span>
</label>
<select
bind:value={enderecoSelecionado}
class="select select-bordered"
disabled={!!editandoAssociacao}
>
<option value="">Selecione um endereço</option>
{#if editandoAssociacao}
<!-- Ao editar, mostrar apenas o endereço atual -->
<option value={editandoAssociacao.endereco._id}>
{editandoAssociacao.endereco.nome} - {editandoAssociacao.endereco.endereco}, {editandoAssociacao.endereco.cidade}/{editandoAssociacao.endereco.estado}
(raio padrão: {editandoAssociacao.endereco.raioMetros}m)
</option>
{:else}
<!-- Ao criar, mostrar apenas endereços disponíveis para associar -->
{#each enderecosParaAssociar as endereco (endereco._id)}
<option value={endereco._id}>
{endereco.nome} - {endereco.endereco}, {endereco.cidade}/{endereco.estado}
(raio padrão: {endereco.raioMetros}m)
</option>
{/each}
{/if}
</select>
<div class="label">
<span class="label-text-alt">
{#if editandoAssociacao}
Não é possível alterar o endereço. Crie uma nova associação se necessário.
{:else}
Selecione um endereço que ainda não está associado a este funcionário
{/if}
</span>
</div>
</div>
<!-- Raio Personalizado -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Raio Personalizado (metros)</span>
</label>
<input
type="number"
bind:value={raioPersonalizado}
min="0"
max="50000"
placeholder="Deixe vazio para usar o raio padrão do endereço"
class="input input-bordered"
/>
<div class="label">
<span class="label-text-alt">
Opcional. Se informado, sobrescreve o raio padrão do endereço para este
funcionário.
</span>
</div>
</div>
<!-- Período de Validade -->
<div class="divider">Período de Validade (opcional)</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Data Início</span>
</label>
<input
type="date"
bind:value={dataInicio}
class="input input-bordered"
/>
<div class="label">
<span class="label-text-alt">Para deslocamentos temporários</span>
</div>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Data Fim</span>
</label>
<input
type="date"
bind:value={dataFim}
class="input input-bordered"
/>
<div class="label">
<span class="label-text-alt">Para deslocamentos temporários</span>
</div>
</div>
</div>
</div>
<div class="modal-action">
<button class="btn btn-ghost" onclick={limparFormulario} disabled={processando}>
Cancelar
</button>
<button class="btn btn-primary" onclick={salvarAssociacao} disabled={processando}>
{#if processando}
<span class="loading loading-spinner loading-sm"></span>
{:else}
Salvar
{/if}
</button>
</div>
</div>
</div>
{/if}
<!-- Barra de Busca -->
<div class="card bg-base-100 shadow-xl mb-6">
<div class="card-body p-4">
<div class="form-control">
<div class="relative">
<Search
class="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-base-content/50"
/>
<input
type="text"
bind:value={termoBusca}
placeholder="Buscar por nome, endereço, cidade ou tipo..."
class="input input-bordered pl-10 w-full"
/>
</div>
</div>
</div>
</div>
<!-- Aviso sobre endereços tipo "sede" -->
{#if associacoes.length === 0}
<div class="alert alert-info mb-6">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="stroke-current shrink-0 h-6 w-6"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
<span>
Este funcionário não possui endereços específicos associados. O sistema usará
automaticamente os endereços tipo "Sede Principal" configurados globalmente.
</span>
</div>
{/if}
<!-- Lista de Associações -->
<div class="grid grid-cols-1 gap-4">
{#if associacoesFiltradas.length === 0}
<div class="card bg-base-100 shadow-xl">
<div class="card-body text-center py-12">
<MapPin class="h-16 w-16 text-base-content/20 mx-auto mb-4" />
<p class="text-lg text-base-content/60">
{termoBusca
? 'Nenhuma associação encontrada'
: 'Nenhum endereço associado'}
</p>
</div>
</div>
{:else}
{#each associacoesFiltradas as associacao (associacao._id)}
{@const raioUsado = associacao.raioMetros}
<div
class="card bg-base-100 shadow-xl {associacao.ativo ? '' : 'opacity-60'}"
>
<div class="card-body">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-3 mb-2">
<h3 class="text-xl font-bold">{associacao.endereco.nome}</h3>
<span
class="badge {associacao.ativo ? 'badge-success' : 'badge-error'}"
>
{associacao.ativo ? 'Ativa' : 'Inativa'}
</span>
<span class="badge badge-outline">
{tiposLabel[associacao.endereco.tipo] || associacao.endereco.tipo}
</span>
</div>
<div class="space-y-1 text-sm">
<p class="text-base-content/80">
<strong>Endereço:</strong> {associacao.endereco.endereco}
</p>
<p class="text-base-content/80">
<strong>Cidade:</strong> {associacao.endereco.cidade}/
{associacao.endereco.estado}
</p>
<p class="text-base-content/80">
<strong>Raio Permitido:</strong>
{#if raioUsado >= 1000}
{@const raioKm = (raioUsado / 1000).toFixed(2)}
{raioKm} km
{:else}
{raioUsado} metros
{/if}
{#if associacao.raioMetrosPersonalizado !== null &&
associacao.raioMetrosPersonalizado !== undefined}
<span class="badge badge-sm badge-primary ml-2">
Personalizado
</span>
{:else}
<span class="badge badge-sm badge-outline ml-2">
Padrão ({associacao.endereco.raioMetros}m)
</span>
{/if}
</p>
{#if associacao.dataInicio || associacao.dataFim}
<p class="text-base-content/80">
<strong>Período:</strong>
{#if associacao.dataInicio}
{@const dataInicioFormatada = new Date(associacao.dataInicio + 'T00:00:00').toLocaleDateString('pt-BR')}
{dataInicioFormatada}
{:else}
...
{/if}
até
{#if associacao.dataFim}
{@const dataFimFormatada = new Date(associacao.dataFim + 'T00:00:00').toLocaleDateString('pt-BR')}
{dataFimFormatada}
{:else}
...
{/if}
</p>
{/if}
</div>
</div>
<div class="flex flex-col gap-2">
<button
class="btn btn-sm btn-outline btn-primary gap-2"
onclick={() => abrirFormularioEdicao(associacao)}
>
<Edit class="h-4 w-4" />
Editar
</button>
<button
class="btn btn-sm btn-outline btn-error gap-2"
onclick={() => removerAssociacao(associacao._id)}
>
<Trash2 class="h-4 w-4" />
Remover
</button>
</div>
</div>
</div>
</div>
{/each}
{/if}
</div>
</div>