feat: Add 'atas' (minutes/records) management feature, and implement various improvements across UI, backend logic, and authentication.

This commit is contained in:
2025-12-02 16:37:48 -03:00
parent 05e7f1181d
commit 4bd9e21748
265 changed files with 29156 additions and 26460 deletions

View File

@@ -0,0 +1,286 @@
<script lang="ts">
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Doc, Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { useConvexClient, useQuery } from 'convex-svelte';
import { Pencil, Plus, Trash2, X } from 'lucide-svelte';
const client = useConvexClient();
// Reactive queries
const atasQuery = useQuery(api.atas.list, {});
const atas = $derived(atasQuery.data || []);
const loadingAtas = $derived(atasQuery.isLoading);
const errorAtas = $derived(atasQuery.error?.message || null);
const empresasQuery = useQuery(api.empresas.list, {});
const empresas = $derived(empresasQuery.data || []);
// Modal state
let showModal = $state(false);
let editingId: string | null = $state(null);
let formData = $state({
numero: '',
numeroSei: '',
empresaId: '' as Id<'empresas'> | '',
dataInicio: '',
dataFim: ''
});
let saving = $state(false);
function openModal(ata?: Doc<'atas'>) {
if (ata) {
editingId = ata._id;
formData = {
numero: ata.numero,
numeroSei: ata.numeroSei,
empresaId: ata.empresaId,
dataInicio: ata.dataInicio || '',
dataFim: ata.dataFim || ''
};
} else {
editingId = null;
formData = {
numero: '',
numeroSei: '',
empresaId: '',
dataInicio: '',
dataFim: ''
};
}
showModal = true;
}
function closeModal() {
showModal = false;
editingId = null;
}
async function handleSubmit(e: Event) {
e.preventDefault();
if (!formData.empresaId) {
alert('Selecione uma empresa.');
return;
}
saving = true;
try {
const payload = {
numero: formData.numero,
numeroSei: formData.numeroSei,
empresaId: formData.empresaId as Id<'empresas'>,
dataInicio: formData.dataInicio || undefined,
dataFim: formData.dataFim || undefined
};
if (editingId) {
await client.mutation(api.atas.update, {
id: editingId as Id<'atas'>,
...payload
});
} else {
await client.mutation(api.atas.create, payload);
}
closeModal();
} catch (e) {
alert('Erro ao salvar: ' + (e as Error).message);
} finally {
saving = false;
}
}
async function handleDelete(id: Id<'atas'>) {
if (!confirm('Tem certeza que deseja excluir esta ata?')) return;
try {
await client.mutation(api.atas.remove, { id });
} catch (e) {
alert('Erro ao excluir: ' + (e as Error).message);
}
}
function getEmpresaNome(id: Id<'empresas'>) {
return empresas.find((e) => e._id === id)?.razao_social || 'Empresa não encontrada';
}
</script>
<div class="container mx-auto p-6">
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-bold">Atas de Registro de Preços</h1>
<button
onclick={() => openModal()}
class="flex items-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
>
<Plus size={20} />
Nova Ata
</button>
</div>
{#if loadingAtas}
<p>Carregando...</p>
{:else if errorAtas}
<p class="text-red-600">{errorAtas}</p>
{:else}
<div class="overflow-hidden rounded-lg bg-white shadow-md">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>Número</th
>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>SEI</th
>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>Empresa</th
>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>Vigência</th
>
<th
class="px-6 py-3 text-right text-xs font-medium tracking-wider text-gray-500 uppercase"
>Ações</th
>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{#each atas as ata (ata._id)}
<tr>
<td class="px-6 py-4 font-medium whitespace-nowrap">{ata.numero}</td>
<td class="px-6 py-4 whitespace-nowrap">{ata.numeroSei}</td>
<td class="px-6 py-4 whitespace-nowrap">{getEmpresaNome(ata.empresaId)}</td>
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500">
{ata.dataInicio || '-'} a {ata.dataFim || '-'}
</td>
<td class="px-6 py-4 text-right text-sm font-medium whitespace-nowrap">
<button
onclick={() => openModal(ata)}
class="mr-4 text-indigo-600 hover:text-indigo-900"
>
<Pencil size={18} />
</button>
<button
onclick={() => handleDelete(ata._id)}
class="text-red-600 hover:text-red-900"
>
<Trash2 size={18} />
</button>
</td>
</tr>
{/each}
{#if atas.length === 0}
<tr>
<td colspan="5" class="px-6 py-4 text-center text-gray-500"
>Nenhuma ata cadastrada.</td
>
</tr>
{/if}
</tbody>
</table>
</div>
{/if}
{#if showModal}
<div
class="fixed inset-0 z-50 flex h-full w-full items-center justify-center overflow-y-auto bg-black/40"
>
<div class="relative w-full max-w-md rounded-lg bg-white p-8 shadow-xl">
<button
onclick={closeModal}
class="absolute top-4 right-4 text-gray-400 hover:text-gray-600"
>
<X size={24} />
</button>
<h2 class="mb-6 text-xl font-bold">{editingId ? 'Editar' : 'Nova'} Ata</h2>
<form onsubmit={handleSubmit}>
<div class="mb-4">
<label class="mb-2 block text-sm font-bold text-gray-700" for="numero">
Número da Ata
</label>
<input
class="focus:shadow-outline w-full appearance-none rounded border px-3 py-2 leading-tight text-gray-700 shadow focus:outline-none"
id="numero"
type="text"
bind:value={formData.numero}
required
/>
</div>
<div class="mb-4">
<label class="mb-2 block text-sm font-bold text-gray-700" for="numeroSei">
Número SEI
</label>
<input
class="focus:shadow-outline w-full appearance-none rounded border px-3 py-2 leading-tight text-gray-700 shadow focus:outline-none"
id="numeroSei"
type="text"
bind:value={formData.numeroSei}
required
/>
</div>
<div class="mb-4">
<label class="mb-2 block text-sm font-bold text-gray-700" for="empresa">
Empresa
</label>
<select
class="focus:shadow-outline w-full rounded border px-3 py-2 leading-tight text-gray-700 shadow focus:outline-none"
id="empresa"
bind:value={formData.empresaId}
required
>
<option value="">Selecione uma empresa...</option>
{#each empresas as empresa (empresa._id)}
<option value={empresa._id}>{empresa.razao_social}</option>
{/each}
</select>
</div>
<div class="mb-6 grid grid-cols-2 gap-4">
<div>
<label class="mb-2 block text-sm font-bold text-gray-700" for="dataInicio">
Data Início
</label>
<input
class="focus:shadow-outline w-full appearance-none rounded border px-3 py-2 leading-tight text-gray-700 shadow focus:outline-none"
id="dataInicio"
type="date"
bind:value={formData.dataInicio}
/>
</div>
<div>
<label class="mb-2 block text-sm font-bold text-gray-700" for="dataFim">
Data Fim
</label>
<input
class="focus:shadow-outline w-full appearance-none rounded border px-3 py-2 leading-tight text-gray-700 shadow focus:outline-none"
id="dataFim"
type="date"
bind:value={formData.dataFim}
/>
</div>
</div>
<div class="flex items-center justify-end">
<button
type="button"
onclick={closeModal}
class="mr-2 rounded bg-gray-300 px-4 py-2 font-bold text-gray-800 hover:bg-gray-400"
>
Cancelar
</button>
<button
type="submit"
disabled={saving}
class="focus:shadow-outline rounded bg-blue-600 px-4 py-2 font-bold text-white hover:bg-blue-700 focus:outline-none disabled:opacity-50"
>
{saving ? 'Salvando...' : 'Salvar'}
</button>
</div>
</form>
</div>
</div>
{/if}
</div>