feat: Implement Ata de Registro de Preços management and linking to objetos and pedidos

This commit is contained in:
2025-12-02 23:29:42 -03:00
parent 8a50fb6f61
commit 4d29501849
7 changed files with 200 additions and 20 deletions

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { FileText, Package, ShoppingCart } from 'lucide-svelte'; import { Building2, FileText, Package, ShoppingCart } from 'lucide-svelte';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import ProtectedRoute from '$lib/components/ProtectedRoute.svelte'; import ProtectedRoute from '$lib/components/ProtectedRoute.svelte';
</script> </script>
@@ -27,7 +27,7 @@
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<a <a
href={resolve('/compras/produtos')} href={resolve('/compras/objetos')}
class="card bg-base-100 border-base-200 hover:border-primary border shadow-md transition-shadow hover:shadow-lg" class="card bg-base-100 border-base-200 hover:border-primary border shadow-md transition-shadow hover:shadow-lg"
> >
<div class="card-body"> <div class="card-body">
@@ -35,10 +35,44 @@
<div class="bg-primary/10 rounded-lg p-2"> <div class="bg-primary/10 rounded-lg p-2">
<Package class="text-primary h-6 w-6" strokeWidth={2} /> <Package class="text-primary h-6 w-6" strokeWidth={2} />
</div> </div>
<h4 class="font-semibold">Produtos</h4> <h4 class="font-semibold">Objetos</h4>
</div> </div>
<p class="text-base-content/70 text-sm"> <p class="text-base-content/70 text-sm">
Cadastro, listagem e edição de produtos e serviços disponíveis para compra. Cadastro, listagem e edição de objetos e serviços disponíveis para compra.
</p>
</div>
</a>
<a
href={resolve('/compras/atas')}
class="card bg-base-100 border-base-200 hover:border-accent border shadow-md transition-shadow hover:shadow-lg"
>
<div class="card-body">
<div class="mb-2 flex items-center gap-3">
<div class="bg-accent/10 rounded-lg p-2">
<FileText class="text-accent h-6 w-6" strokeWidth={2} />
</div>
<h4 class="font-semibold">Atas de Registro</h4>
</div>
<p class="text-base-content/70 text-sm">
Gerencie Atas de Registro de Preços e seus vínculos com objetos.
</p>
</div>
</a>
<a
href={resolve('/licitacoes/empresas')}
class="card bg-base-100 border-base-200 hover:border-info border shadow-md transition-shadow hover:shadow-lg"
>
<div class="card-body">
<div class="mb-2 flex items-center gap-3">
<div class="bg-info/10 rounded-lg p-2">
<Building2 class="text-info h-6 w-6" strokeWidth={2} />
</div>
<h4 class="font-semibold">Empresas</h4>
</div>
<p class="text-base-content/70 text-sm">
Cadastro e gestão de empresas fornecedoras e seus contatos.
</p> </p>
</div> </div>
</a> </a>

View File

@@ -7,12 +7,15 @@
const client = useConvexClient(); const client = useConvexClient();
// Reactive query // Reactive queries
const objetosQuery = useQuery(api.objetos.list, {}); const objetosQuery = useQuery(api.objetos.list, {});
let objetos = $derived(objetosQuery.data || []); let objetos = $derived(objetosQuery.data || []);
let loading = $derived(objetosQuery.isLoading); let loading = $derived(objetosQuery.isLoading);
let error = $derived(objetosQuery.error?.message || null); let error = $derived(objetosQuery.error?.message || null);
const atasQuery = useQuery(api.atas.list, {});
let atas = $derived(atasQuery.data || []);
// Modal state // Modal state
let showModal = $state(false); let showModal = $state(false);
let editingId: string | null = $state(null); let editingId: string | null = $state(null);
@@ -23,13 +26,16 @@
codigoEfisco: '', codigoEfisco: '',
codigoCatmat: '', codigoCatmat: '',
codigoCatserv: '', codigoCatserv: '',
unidade: '' unidade: '',
atas: [] as Id<'atas'>[]
}); });
let saving = $state(false); let saving = $state(false);
function openModal(objeto?: Doc<'objetos'>) { async function openModal(objeto?: Doc<'objetos'>) {
if (objeto) { if (objeto) {
editingId = objeto._id; editingId = objeto._id;
// Fetch linked Atas
const linkedAtas = await client.query(api.objetos.getAtas, { objetoId: objeto._id });
formData = { formData = {
nome: objeto.nome, nome: objeto.nome,
valorEstimado: maskCurrencyBRL(objeto.valorEstimado || ''), valorEstimado: maskCurrencyBRL(objeto.valorEstimado || ''),
@@ -37,7 +43,8 @@
codigoEfisco: objeto.codigoEfisco || '', codigoEfisco: objeto.codigoEfisco || '',
codigoCatmat: objeto.codigoCatmat || '', codigoCatmat: objeto.codigoCatmat || '',
codigoCatserv: objeto.codigoCatserv || '', codigoCatserv: objeto.codigoCatserv || '',
unidade: objeto.unidade || '' unidade: objeto.unidade || '',
atas: linkedAtas.map((a) => a._id)
}; };
} else { } else {
editingId = null; editingId = null;
@@ -48,7 +55,8 @@
codigoEfisco: '', codigoEfisco: '',
codigoCatmat: '', codigoCatmat: '',
codigoCatserv: '', codigoCatserv: '',
unidade: '' unidade: '',
atas: []
}; };
} }
showModal = true; showModal = true;
@@ -70,7 +78,8 @@
codigoEfisco: formData.codigoEfisco, codigoEfisco: formData.codigoEfisco,
codigoCatmat: formData.codigoCatmat || undefined, codigoCatmat: formData.codigoCatmat || undefined,
codigoCatserv: formData.codigoCatserv || undefined, codigoCatserv: formData.codigoCatserv || undefined,
unidade: formData.unidade unidade: formData.unidade,
atas: formData.atas
}; };
if (editingId) { if (editingId) {
@@ -97,6 +106,14 @@
alert('Erro ao excluir: ' + (e as Error).message); alert('Erro ao excluir: ' + (e as Error).message);
} }
} }
function toggleAtaSelection(ataId: Id<'atas'>) {
if (formData.atas.includes(ataId)) {
formData.atas = formData.atas.filter((id) => id !== ataId);
} else {
formData.atas = [...formData.atas, ataId];
}
}
</script> </script>
<div class="container mx-auto p-6"> <div class="container mx-auto p-6">
@@ -303,6 +320,31 @@
/> />
</div> </div>
<div class="mb-6">
<label class="mb-2 block text-sm font-bold text-gray-700" for="atas">
Vincular Atas
</label>
<div class="max-h-40 overflow-y-auto rounded border p-2">
{#each atas as ata (ata._id)}
<div class="mb-2 flex items-center">
<input
type="checkbox"
id={`ata-${ata._id}`}
checked={formData.atas.includes(ata._id)}
onchange={() => toggleAtaSelection(ata._id)}
class="mr-2 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<label for={`ata-${ata._id}`} class="text-sm text-gray-700">
{ata.numero} ({ata.numeroSei})
</label>
</div>
{/each}
{#if atas.length === 0}
<p class="text-sm text-gray-500">Nenhuma ata disponível.</p>
{/if}
</div>
</div>
<div class="flex items-center justify-end"> <div class="flex items-center justify-end">
<button <button
type="button" type="button"

View File

@@ -30,6 +30,8 @@
quantidade: number; quantidade: number;
modalidade: 'dispensa' | 'inexgibilidade' | 'adesao' | 'consumo'; modalidade: 'dispensa' | 'inexgibilidade' | 'adesao' | 'consumo';
acaoId?: Id<'acoes'>; acaoId?: Id<'acoes'>;
ataId?: Id<'atas'>;
ataNumero?: string; // For display
}; };
let selectedItems = $state<SelectedItem[]>([]); let selectedItems = $state<SelectedItem[]>([]);
@@ -42,19 +44,28 @@
quantidade: number; quantidade: number;
modalidade: 'dispensa' | 'inexgibilidade' | 'adesao' | 'consumo'; modalidade: 'dispensa' | 'inexgibilidade' | 'adesao' | 'consumo';
acaoId: string; // using string to handle empty select acaoId: string; // using string to handle empty select
ataId: string; // using string to handle empty select
}>({ }>({
objeto: null, objeto: null,
quantidade: 1, quantidade: 1,
modalidade: 'consumo', modalidade: 'consumo',
acaoId: '' acaoId: '',
ataId: ''
}); });
function openItemModal(objeto: Doc<'objetos'>) { let availableAtas = $state<Doc<'atas'>[]>([]);
async function openItemModal(objeto: Doc<'objetos'>) {
// Fetch linked Atas for this object
const linkedAtas = await client.query(api.objetos.getAtas, { objetoId: objeto._id });
availableAtas = linkedAtas;
itemConfig = { itemConfig = {
objeto, objeto,
quantidade: 1, quantidade: 1,
modalidade: 'consumo', modalidade: 'consumo',
acaoId: '' acaoId: '',
ataId: ''
}; };
showItemModal = true; showItemModal = true;
searchQuery = ''; // Clear search searchQuery = ''; // Clear search
@@ -63,18 +74,23 @@
function closeItemModal() { function closeItemModal() {
showItemModal = false; showItemModal = false;
itemConfig.objeto = null; itemConfig.objeto = null;
availableAtas = [];
} }
function confirmAddItem() { function confirmAddItem() {
if (!itemConfig.objeto) return; if (!itemConfig.objeto) return;
const selectedAta = availableAtas.find((a) => a._id === itemConfig.ataId);
selectedItems = [ selectedItems = [
...selectedItems, ...selectedItems,
{ {
objeto: itemConfig.objeto, objeto: itemConfig.objeto,
quantidade: itemConfig.quantidade, quantidade: itemConfig.quantidade,
modalidade: itemConfig.modalidade, modalidade: itemConfig.modalidade,
acaoId: itemConfig.acaoId ? (itemConfig.acaoId as Id<'acoes'>) : undefined acaoId: itemConfig.acaoId ? (itemConfig.acaoId as Id<'acoes'>) : undefined,
ataId: itemConfig.ataId ? (itemConfig.ataId as Id<'atas'>) : undefined,
ataNumero: selectedAta?.numero
} }
]; ];
checkExisting(); checkExisting();
@@ -191,7 +207,8 @@
valorEstimado: item.objeto.valorEstimado, valorEstimado: item.objeto.valorEstimado,
quantidade: item.quantidade, quantidade: item.quantidade,
modalidade: item.modalidade, modalidade: item.modalidade,
acaoId: item.acaoId acaoId: item.acaoId,
ataId: item.ataId
}) })
) )
); );
@@ -287,6 +304,10 @@
<span class="ml-2 font-semibold text-gray-600">Ação:</span> <span class="ml-2 font-semibold text-gray-600">Ação:</span>
{getAcaoNome(item.acaoId)} {getAcaoNome(item.acaoId)}
{/if} {/if}
{#if item.ataNumero}
<span class="ml-2 font-semibold text-gray-600">Ata:</span>
{item.ataNumero}
{/if}
</div> </div>
</div> </div>
<button <button
@@ -410,6 +431,24 @@
</select> </select>
</div> </div>
{#if availableAtas.length > 0}
<div class="mb-4">
<label class="mb-1 block text-sm font-bold text-gray-700" for="itemAta">
Ata de Registro de Preços (Opcional)
</label>
<select
id="itemAta"
class="w-full rounded border px-3 py-2"
bind:value={itemConfig.ataId}
>
<option value="">Selecione uma ata...</option>
{#each availableAtas as ata (ata._id)}
<option value={ata._id}>Ata {ata.numero} ({ata.numeroSei})</option>
{/each}
</select>
</div>
{/if}
<div class="mb-6"> <div class="mb-6">
<label class="mb-1 block text-sm font-bold text-gray-700" for="itemAcao"> <label class="mb-1 block text-sm font-bold text-gray-700" for="itemAcao">
Ação (Opcional) Ação (Opcional)

View File

@@ -27,17 +27,35 @@ export const create = mutation({
codigoEfisco: v.string(), codigoEfisco: v.string(),
codigoCatmat: v.optional(v.string()), codigoCatmat: v.optional(v.string()),
codigoCatserv: v.optional(v.string()), codigoCatserv: v.optional(v.string()),
unidade: v.string() unidade: v.string(),
atas: v.optional(v.array(v.id('atas')))
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const user = await getCurrentUserFunction(ctx); const user = await getCurrentUserFunction(ctx);
if (!user) throw new Error('Unauthorized'); if (!user) throw new Error('Unauthorized');
return await ctx.db.insert('objetos', { const objetoId = await ctx.db.insert('objetos', {
...args, nome: args.nome,
valorEstimado: args.valorEstimado,
tipo: args.tipo,
codigoEfisco: args.codigoEfisco,
codigoCatmat: args.codigoCatmat,
codigoCatserv: args.codigoCatserv,
unidade: args.unidade,
criadoPor: user._id, criadoPor: user._id,
criadoEm: Date.now() criadoEm: Date.now()
}); });
if (args.atas) {
for (const ataId of args.atas) {
await ctx.db.insert('atasObjetos', {
ataId,
objetoId
});
}
}
return objetoId;
} }
}); });
@@ -50,7 +68,8 @@ export const update = mutation({
codigoEfisco: v.string(), codigoEfisco: v.string(),
codigoCatmat: v.optional(v.string()), codigoCatmat: v.optional(v.string()),
codigoCatserv: v.optional(v.string()), codigoCatserv: v.optional(v.string()),
unidade: v.string() unidade: v.string(),
atas: v.optional(v.array(v.id('atas')))
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const user = await getCurrentUserFunction(ctx); const user = await getCurrentUserFunction(ctx);
@@ -65,6 +84,39 @@ export const update = mutation({
codigoCatserv: args.codigoCatserv, codigoCatserv: args.codigoCatserv,
unidade: args.unidade unidade: args.unidade
}); });
if (args.atas !== undefined) {
// Remove existing links
const existingLinks = await ctx.db
.query('atasObjetos')
.withIndex('by_objetoId', (q) => q.eq('objetoId', args.id))
.collect();
for (const link of existingLinks) {
await ctx.db.delete(link._id);
}
// Add new links
for (const ataId of args.atas) {
await ctx.db.insert('atasObjetos', {
ataId,
objetoId: args.id
});
}
}
}
});
export const getAtas = query({
args: { objetoId: v.id('objetos') },
handler: async (ctx, args) => {
const links = await ctx.db
.query('atasObjetos')
.withIndex('by_objetoId', (q) => q.eq('objetoId', args.objetoId))
.collect();
const atas = await Promise.all(links.map((link) => ctx.db.get(link.ataId)));
return atas.filter((ata) => ata !== null);
} }
}); });

View File

@@ -76,6 +76,7 @@ export const getItems = query({
_creationTime: v.number(), _creationTime: v.number(),
pedidoId: v.id('pedidos'), pedidoId: v.id('pedidos'),
objetoId: v.id('objetos'), objetoId: v.id('objetos'),
ataId: v.optional(v.id('atas')),
acaoId: v.optional(v.id('acoes')), acaoId: v.optional(v.id('acoes')),
modalidade: v.union( modalidade: v.union(
v.literal('dispensa'), v.literal('dispensa'),
@@ -341,6 +342,7 @@ export const addItem = mutation({
args: { args: {
pedidoId: v.id('pedidos'), pedidoId: v.id('pedidos'),
objetoId: v.id('objetos'), objetoId: v.id('objetos'),
ataId: v.optional(v.id('atas')),
acaoId: v.optional(v.id('acoes')), acaoId: v.optional(v.id('acoes')),
modalidade: v.union( modalidade: v.union(
v.literal('dispensa'), v.literal('dispensa'),
@@ -369,6 +371,7 @@ export const addItem = mutation({
q.eq(q.field('objetoId'), args.objetoId), q.eq(q.field('objetoId'), args.objetoId),
q.eq(q.field('adicionadoPor'), user.funcionarioId), q.eq(q.field('adicionadoPor'), user.funcionarioId),
q.eq(q.field('acaoId'), args.acaoId), q.eq(q.field('acaoId'), args.acaoId),
q.eq(q.field('ataId'), args.ataId),
q.eq(q.field('modalidade'), args.modalidade) q.eq(q.field('modalidade'), args.modalidade)
) )
) )
@@ -395,6 +398,7 @@ export const addItem = mutation({
await ctx.db.insert('objetoItems', { await ctx.db.insert('objetoItems', {
pedidoId: args.pedidoId, pedidoId: args.pedidoId,
objetoId: args.objetoId, objetoId: args.objetoId,
ataId: args.ataId,
acaoId: args.acaoId, acaoId: args.acaoId,
modalidade: args.modalidade, modalidade: args.modalidade,
valorEstimado: args.valorEstimado, valorEstimado: args.valorEstimado,
@@ -412,6 +416,7 @@ export const addItem = mutation({
valor: args.valorEstimado, valor: args.valorEstimado,
quantidade: args.quantidade, quantidade: args.quantidade,
acaoId: args.acaoId, acaoId: args.acaoId,
ataId: args.ataId,
modalidade: args.modalidade modalidade: args.modalidade
}), }),
data: Date.now() data: Date.now()

View File

@@ -15,5 +15,12 @@ export const atasTables = {
}) })
.index('by_numero', ['numero']) .index('by_numero', ['numero'])
.index('by_empresaId', ['empresaId']) .index('by_empresaId', ['empresaId'])
.index('by_numeroSei', ['numeroSei']) .index('by_numeroSei', ['numeroSei']),
atasObjetos: defineTable({
ataId: v.id('atas'),
objetoId: v.id('objetos')
})
.index('by_ataId', ['ataId'])
.index('by_objetoId', ['objetoId'])
}; };

View File

@@ -24,6 +24,7 @@ export const pedidosTables = {
objetoItems: defineTable({ objetoItems: defineTable({
pedidoId: v.id('pedidos'), pedidoId: v.id('pedidos'),
objetoId: v.id('objetos'), // was produtoId objetoId: v.id('objetos'), // was produtoId
ataId: v.optional(v.id('atas')),
acaoId: v.optional(v.id('acoes')), // Moved from pedidos acaoId: v.optional(v.id('acoes')), // Moved from pedidos
modalidade: v.union( modalidade: v.union(
v.literal('dispensa'), v.literal('dispensa'),