diff --git a/apps/web/src/routes/(dashboard)/compras/atas/+page.svelte b/apps/web/src/routes/(dashboard)/compras/atas/+page.svelte index b47faea..61042c0 100644 --- a/apps/web/src/routes/(dashboard)/compras/atas/+page.svelte +++ b/apps/web/src/routes/(dashboard)/compras/atas/+page.svelte @@ -2,7 +2,7 @@ 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'; + import { Pencil, Plus, Trash2, X, Search, Check } from 'lucide-svelte'; const client = useConvexClient(); @@ -15,6 +15,9 @@ const empresasQuery = useQuery(api.empresas.list, {}); let empresas = $derived(empresasQuery.data || []); + const objetosQuery = useQuery(api.objetos.list, {}); + let objetos = $derived(objetosQuery.data || []); + // Modal state let showModal = $state(false); let editingId: string | null = $state(null); @@ -25,9 +28,16 @@ dataInicio: '', dataFim: '' }); + let selectedObjetos = $state[]>([]); + let searchObjeto = $state(''); let saving = $state(false); - function openModal(ata?: Doc<'atas'>) { + // Derived state for filtered objects + let filteredObjetos = $derived( + objetos.filter((obj) => obj.nome.toLowerCase().includes(searchObjeto.toLowerCase())) + ); + + async function openModal(ata?: Doc<'atas'>) { if (ata) { editingId = ata._id; formData = { @@ -37,6 +47,9 @@ dataInicio: ata.dataInicio || '', dataFim: ata.dataFim || '' }; + // Fetch linked objects + const linkedObjetos = await client.query(api.atas.getObjetos, { id: ata._id }); + selectedObjetos = linkedObjetos.map((o) => o._id); } else { editingId = null; formData = { @@ -46,7 +59,9 @@ dataInicio: '', dataFim: '' }; + selectedObjetos = []; } + searchObjeto = ''; showModal = true; } @@ -55,6 +70,14 @@ editingId = null; } + function toggleObjeto(id: Id<'objetos'>) { + if (selectedObjetos.includes(id)) { + selectedObjetos = selectedObjetos.filter((oid) => oid !== id); + } else { + selectedObjetos = [...selectedObjetos, id]; + } + } + async function handleSubmit(e: Event) { e.preventDefault(); if (!formData.empresaId) { @@ -68,7 +91,8 @@ numeroSei: formData.numeroSei, empresaId: formData.empresaId as Id<'empresas'>, dataInicio: formData.dataInicio || undefined, - dataFim: formData.dataFim || undefined + dataFim: formData.dataFim || undefined, + objetosIds: selectedObjetos }; if (editingId) { @@ -185,7 +209,7 @@
-
+
+ {/each} +
+ {/if} +
+

+ Selecione os objetos que fazem parte desta Ata. +

-
+
+ {#if newItem.objetoId} +
+ + +
+ {/if}
Ação + Ata Total - {maskCurrencyBRL(item.valorEstimado) || 'R$ 0,00'} + {#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes'} + + setEditingField( + item._id, + 'valorEstimado', + maskCurrencyBRL(e.currentTarget.value) + )} + onblur={() => persistItemChanges(item)} + placeholder="R$ 0,00" + /> + {:else} + {maskCurrencyBRL(item.valorEstimado) || 'R$ 0,00'} + {/if} - {item.modalidade} + {#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes'} + + {:else} + {item.modalidade} + {/if} - {getAcaoName(item.acaoId)} + {#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes'} + + {:else} + {getAcaoName(item.acaoId)} + {/if} + + + {#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes'} + + {:else} + {#if item.ataId} + {#each getAtasForObjeto(item.objetoId) as ata (ata._id)} + {#if ata._id === item.ataId} + Ata {ata.numero} + {/if} + {/each} + {:else} + - + {/if} + {/if} R$ {calculateItemTotal(item.valorEstimado, item.quantidade) @@ -602,12 +839,20 @@ .replace('.', ',')} + {#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes'} {/if} @@ -629,6 +874,81 @@
+ {#if showDetailsModal && selectedObjeto} +
+
+ +

Detalhes do Objeto

+ +
+
+
Nome
+

{selectedObjeto.nome}

+
+ +
+
+
Tipo
+ + {selectedObjeto.tipo === 'material' ? 'Material' : 'Serviço'} + +
+
+
Unidade
+

{selectedObjeto.unidade}

+
+
+ +
+
+
Código Efisco
+

{selectedObjeto.codigoEfisco}

+
+
+ {#if selectedObjeto.tipo === 'material'} +
Código Catmat
+

{selectedObjeto.codigoCatmat || '-'}

+ {:else} +
Código Catserv
+

{selectedObjeto.codigoCatserv || '-'}

+ {/if} +
+
+ +
+
Valor Estimado (Unitário)
+

+ {maskCurrencyBRL(selectedObjeto.valorEstimado || '') || 'R$ 0,00'} +

+
+
+ +
+ +
+
+
+ {/if} +

Histórico

@@ -640,7 +960,7 @@
-
+
{getHistoryIcon(entry.acao)}
diff --git a/apps/web/src/routes/(dashboard)/pedidos/novo/+page.svelte b/apps/web/src/routes/(dashboard)/pedidos/novo/+page.svelte index c3fa834..3416999 100644 --- a/apps/web/src/routes/(dashboard)/pedidos/novo/+page.svelte +++ b/apps/web/src/routes/(dashboard)/pedidos/novo/+page.svelte @@ -2,7 +2,7 @@ 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 { Plus, Trash2, X } from 'lucide-svelte'; + import { Plus, Trash2, X, Info } from 'lucide-svelte'; import { goto } from '$app/navigation'; import { resolve } from '$app/paths'; @@ -32,6 +32,7 @@ acaoId?: Id<'acoes'>; ataId?: Id<'atas'>; ataNumero?: string; // For display + ata?: Doc<'atas'>; // Full ata object for details }; let selectedItems = $state([]); @@ -55,6 +56,20 @@ let availableAtas = $state[]>([]); + // Item Details Modal + let showDetailsModal = $state(false); + let detailsItem = $state(null); + + function openDetails(item: SelectedItem) { + detailsItem = item; + showDetailsModal = true; + } + + function closeDetails() { + showDetailsModal = false; + detailsItem = null; + } + async function openItemModal(objeto: Doc<'objetos'>) { // Fetch linked Atas for this object const linkedAtas = await client.query(api.objetos.getAtas, { objetoId: objeto._id }); @@ -90,7 +105,8 @@ modalidade: itemConfig.modalidade, acaoId: itemConfig.acaoId ? (itemConfig.acaoId as Id<'acoes'>) : undefined, ataId: itemConfig.ataId ? (itemConfig.ataId as Id<'atas'>) : undefined, - ataNumero: selectedAta?.numero + ataNumero: selectedAta?.numero, + ata: selectedAta } ]; checkExisting(); @@ -223,58 +239,70 @@ } -
-

Novo Pedido

+
+

Novo Pedido

-
+
{#if error} -
- {error} +
+

Erro

+

{error}

{/if} - -
- - -

Você pode adicionar o número SEI posteriormente.

+ + +
+

Informações Básicas

+
+ + +

+ Você pode adicionar o número SEI posteriormente. +

+
-
- + +
+

Adicionar Objetos ao Pedido

-
+
+ {#if searchQuery.length > 0 && searchResults} -
+
{#if searchResults.length === 0} -
Nenhum objeto encontrado.
+
Nenhum objeto encontrado.
{:else} -
    +
      {#each searchResults as objeto (objeto._id)}
    • @@ -286,68 +314,97 @@
{#if selectedItems.length > 0} -
-

Itens Selecionados:

+
+

+ Itens Selecionados ({selectedItems.length}) +

{#each selectedItems as item, index (index)} -
+
-
-
{item.objeto.nome}
-
- Qtd: {item.quantidade} | Unid: {item.objeto.unidade} -
-
- Modalidade: - {item.modalidade} - {#if item.acaoId} - Ação: - {getAcaoNome(item.acaoId)} - {/if} +
+
+

{item.objeto.nome}

{#if item.ataNumero} - Ata: - {item.ataNumero} + + Ata {item.ataNumero} + + {/if} +
+
+ Qtd: {item.quantidade} {item.objeto.unidade} + Modalidade: {item.modalidade} + {#if item.acaoId} + Ação: {getAcaoNome(item.acaoId)} {/if}
- +
+ + +
{/each}
+ {:else} +
+

+ Nenhum item adicionado. Use a busca acima para adicionar objetos ao pedido. +

+
{/if}
+ {#if warning}
- {warning} +

Aviso

+

{warning}

{/if} {#if checking} -

Verificando pedidos existentes...

+

Verificando pedidos existentes...

{/if} {#if existingPedidos.length > 0} -
-

Pedidos similares encontrados:

+
+

Pedidos similares encontrados:

{/if} -
+ +
Cancelar @@ -385,92 +438,191 @@
+ {#if showItemModal && itemConfig.objeto}
-
+
-

Configurar Item

-
-

{itemConfig.objeto.nome}

-

Unidade: {itemConfig.objeto.unidade}

+ +

Configurar Item

+ +
+

{itemConfig.objeto.nome}

+

Unidade: {itemConfig.objeto.unidade}

+

+ Valor estimado: {itemConfig.objeto.valorEstimado} +

-
- - -
+
+
+ + +
-
- - -
- - {#if availableAtas.length > 0} -
-
+ {/if} + + + {#if showDetailsModal && detailsItem} +
+
+ + +

Detalhes do Item

+ +
+
+

Objeto

+

Nome: {detailsItem.objeto.nome}

+

Unidade: {detailsItem.objeto.unidade}

+

+ Valor Estimado: + {detailsItem.objeto.valorEstimado} +

+
+ +
+

Pedido

+

Quantidade: {detailsItem.quantidade}

+

Modalidade: {detailsItem.modalidade}

+ {#if detailsItem.acaoId} +

+ Ação: + {getAcaoNome(detailsItem.acaoId)} +

+ {/if} +
+ + {#if detailsItem.ata} +
+

Ata de Registro de Preços

+

Número: {detailsItem.ata.numero}

+

+ Processo SEI: + {detailsItem.ata.numeroSei} +

+ {#if detailsItem.ata.dataInicio} +

+ Vigência: + {detailsItem.ata.dataInicio} até {detailsItem.ata.dataFim || 'Indefinido'} +

+ {/if} +
+ {:else} +
+

Nenhuma Ata vinculada a este item.

+
+ {/if} +
+ +
+
diff --git a/packages/backend/convex/atas.ts b/packages/backend/convex/atas.ts index 71668b1..a09a62c 100644 --- a/packages/backend/convex/atas.ts +++ b/packages/backend/convex/atas.ts @@ -1,5 +1,6 @@ import { v } from 'convex/values'; import { mutation, query } from './_generated/server'; +import type { Id } from './_generated/dataModel'; import { getCurrentUserFunction } from './auth'; export const list = query({ @@ -16,6 +17,47 @@ export const get = query({ } }); +export const getObjetos = query({ + args: { id: v.id('atas') }, + handler: async (ctx, args) => { + const links = await ctx.db + .query('atasObjetos') + .withIndex('by_ataId', (q) => q.eq('ataId', args.id)) + .collect(); + + const objetos = await Promise.all(links.map((link) => ctx.db.get(link.objetoId))); + return objetos.filter((obj) => obj !== null); + } +}); + +export const listByObjetoIds = query({ + args: { + objetoIds: v.array(v.id('objetos')) + }, + handler: async (ctx, args) => { + if (args.objetoIds.length === 0) return []; + + // Buscar todos os vínculos ata-objeto para os objetos informados + const links = []; + for (const objetoId of args.objetoIds) { + const partial = await ctx.db + .query('atasObjetos') + .withIndex('by_objetoId', (q) => q.eq('objetoId', objetoId as Id<'objetos'>)) + .collect(); + links.push(...partial); + } + + const ataIds = Array.from( + new Set(links.map((l) => l.ataId as Id<'atas'>)) + ); + + if (ataIds.length === 0) return []; + + const atas = await Promise.all(ataIds.map((id) => ctx.db.get(id))); + return atas.filter((a): a is NonNullable => a !== null); + } +}); + export const create = mutation({ args: { numero: v.string(), @@ -23,18 +65,35 @@ export const create = mutation({ dataFim: v.optional(v.string()), empresaId: v.id('empresas'), pdf: v.optional(v.string()), - numeroSei: v.string() + numeroSei: v.string(), + objetosIds: v.optional(v.array(v.id('objetos'))) }, handler: async (ctx, args) => { const user = await getCurrentUserFunction(ctx); if (!user) throw new Error('Unauthorized'); - return await ctx.db.insert('atas', { - ...args, + const ataId = await ctx.db.insert('atas', { + numero: args.numero, + dataInicio: args.dataInicio, + dataFim: args.dataFim, + empresaId: args.empresaId, + pdf: args.pdf, + numeroSei: args.numeroSei, criadoPor: user._id, criadoEm: Date.now(), atualizadoEm: Date.now() }); + + if (args.objetosIds) { + for (const objetoId of args.objetosIds) { + await ctx.db.insert('atasObjetos', { + ataId, + objetoId + }); + } + } + + return ataId; } }); @@ -46,7 +105,8 @@ export const update = mutation({ dataFim: v.optional(v.string()), empresaId: v.id('empresas'), pdf: v.optional(v.string()), - numeroSei: v.string() + numeroSei: v.string(), + objetosIds: v.optional(v.array(v.id('objetos'))) }, handler: async (ctx, args) => { const user = await getCurrentUserFunction(ctx); @@ -61,6 +121,26 @@ export const update = mutation({ numeroSei: args.numeroSei, atualizadoEm: Date.now() }); + + if (args.objetosIds !== undefined) { + // Remove existing links + const existingLinks = await ctx.db + .query('atasObjetos') + .withIndex('by_ataId', (q) => q.eq('ataId', args.id)) + .collect(); + + for (const link of existingLinks) { + await ctx.db.delete(link._id); + } + + // Add new links + for (const objetoId of args.objetosIds) { + await ctx.db.insert('atasObjetos', { + ataId: args.id, + objetoId + }); + } + } } }); @@ -72,6 +152,16 @@ export const remove = mutation({ const user = await getCurrentUserFunction(ctx); if (!user) throw new Error('Unauthorized'); + // Remove linked objects + const links = await ctx.db + .query('atasObjetos') + .withIndex('by_ataId', (q) => q.eq('ataId', args.id)) + .collect(); + + for (const link of links) { + await ctx.db.delete(link._id); + } + await ctx.db.delete(args.id); } }); diff --git a/packages/backend/convex/pedidos.ts b/packages/backend/convex/pedidos.ts index bef7710..3e571c7 100644 --- a/packages/backend/convex/pedidos.ts +++ b/packages/backend/convex/pedidos.ts @@ -498,6 +498,71 @@ export const removeItem = mutation({ } }); +export const updateItem = mutation({ + args: { + itemId: v.id('objetoItems'), + valorEstimado: v.string(), + modalidade: v.union( + v.literal('dispensa'), + v.literal('inexgibilidade'), + v.literal('adesao'), + v.literal('consumo') + ), + acaoId: v.optional(v.id('acoes')), + ataId: v.optional(v.id('atas')) + }, + returns: v.null(), + handler: async (ctx, args) => { + const user = await getUsuarioAutenticado(ctx); + + if (!user.funcionarioId) { + throw new Error('Usuário não vinculado a um funcionário.'); + } + + const item = await ctx.db.get(args.itemId); + if (!item) throw new Error('Item não encontrado.'); + + // Apenas quem adicionou o item pode editá-lo + const isOwner = item.adicionadoPor === user.funcionarioId; + if (!isOwner) { + throw new Error('Apenas quem adicionou este item pode editá-lo.'); + } + + const oldValues = { + valorEstimado: item.valorEstimado, + modalidade: item.modalidade, + acaoId: 'acaoId' in item ? item.acaoId : undefined, + ataId: 'ataId' in item ? item.ataId : undefined + }; + + await ctx.db.patch(args.itemId, { + valorEstimado: args.valorEstimado, + modalidade: args.modalidade, + acaoId: args.acaoId, + ataId: args.ataId + }); + + await ctx.db.patch(item.pedidoId, { atualizadoEm: Date.now() }); + + await ctx.db.insert('historicoPedidos', { + pedidoId: item.pedidoId, + usuarioId: user._id, + acao: 'edicao_item', + detalhes: JSON.stringify({ + objetoId: item.objetoId, + de: oldValues, + para: { + valorEstimado: args.valorEstimado, + modalidade: args.modalidade, + acaoId: args.acaoId, + ataId: args.ataId + } + }), + data: Date.now() + }); + } +}); + export const updateStatus = mutation({ args: { pedidoId: v.id('pedidos'),