From 7746dce25a14ca593d42e397ea1e6971a48a09fb Mon Sep 17 00:00:00 2001 From: killer-cf Date: Wed, 3 Dec 2025 23:37:26 -0300 Subject: [PATCH] feat: Implement batch item removal and pedido splitting for pedidos, and add document management for atas. --- .../(dashboard)/compras/atas/+page.svelte | 155 ++++++++- .../(dashboard)/pedidos/[id]/+page.svelte | 310 ++++++++++++++++-- .../(dashboard)/pedidos/novo/+page.svelte | 113 ++++++- packages/backend/convex/atas.ts | 147 ++++++--- packages/backend/convex/pedidos.ts | 157 ++++++++- packages/backend/convex/tables/atas.ts | 14 +- 6 files changed, 796 insertions(+), 100 deletions(-) diff --git a/apps/web/src/routes/(dashboard)/compras/atas/+page.svelte b/apps/web/src/routes/(dashboard)/compras/atas/+page.svelte index 61042c0..0fdfef1 100644 --- a/apps/web/src/routes/(dashboard)/compras/atas/+page.svelte +++ b/apps/web/src/routes/(dashboard)/compras/atas/+page.svelte @@ -37,6 +37,14 @@ objetos.filter((obj) => obj.nome.toLowerCase().includes(searchObjeto.toLowerCase())) ); + // Document state + let mainPdfFile: File | null = $state(null); + let attachmentFiles: File[] = $state([]); + let attachments = $state; nome: string; url: string | null }>>( + [] + ); + let uploading = $state(false); + async function openModal(ata?: Doc<'atas'>) { if (ata) { editingId = ata._id; @@ -50,6 +58,9 @@ // Fetch linked objects const linkedObjetos = await client.query(api.atas.getObjetos, { id: ata._id }); selectedObjetos = linkedObjetos.map((o) => o._id); + + // Fetch attachments + attachments = await client.query(api.atas.getDocumentos, { ataId: ata._id }); } else { editingId = null; formData = { @@ -60,7 +71,10 @@ dataFim: '' }; selectedObjetos = []; + attachments = []; } + mainPdfFile = null; + attachmentFiles = []; searchObjeto = ''; showModal = true; } @@ -78,6 +92,17 @@ } } + async function uploadFile(file: File) { + const uploadUrl = await client.mutation(api.atas.generateUploadUrl, {}); + const result = await fetch(uploadUrl, { + method: 'POST', + headers: { 'Content-Type': file.type }, + body: file + }); + const { storageId } = await result.json(); + return storageId; + } + async function handleSubmit(e: Event) { e.preventDefault(); if (!formData.empresaId) { @@ -86,28 +111,53 @@ } saving = true; try { + let pdfStorageId = undefined; + if (mainPdfFile) { + pdfStorageId = await uploadFile(mainPdfFile); + } + const payload = { numero: formData.numero, numeroSei: formData.numeroSei, empresaId: formData.empresaId as Id<'empresas'>, dataInicio: formData.dataInicio || undefined, dataFim: formData.dataFim || undefined, - objetosIds: selectedObjetos + objetosIds: selectedObjetos, + pdf: pdfStorageId }; + let ataId: Id<'atas'>; if (editingId) { await client.mutation(api.atas.update, { id: editingId as Id<'atas'>, ...payload }); + ataId = editingId as Id<'atas'>; } else { - await client.mutation(api.atas.create, payload); + ataId = await client.mutation(api.atas.create, payload); } + + // Upload attachments + if (attachmentFiles.length > 0) { + uploading = true; + for (const file of attachmentFiles) { + const storageId = await uploadFile(file); + await client.mutation(api.atas.saveDocumento, { + ataId, + nome: file.name, + storageId, + tipo: file.type, + tamanho: file.size + }); + } + } + closeModal(); } catch (e) { alert('Erro ao salvar: ' + (e as Error).message); } finally { saving = false; + uploading = false; } } @@ -120,9 +170,38 @@ } } + async function handleDeleteAttachment(docId: Id<'atasDocumentos'>) { + if (!confirm('Tem certeza que deseja excluir este anexo?')) return; + try { + await client.mutation(api.atas.removeDocumento, { id: docId }); + // Refresh attachments list + if (editingId) { + attachments = await client.query(api.atas.getDocumentos, { + ataId: editingId as Id<'atas'> + }); + } + } catch (e) { + alert('Erro ao excluir anexo: ' + (e as Error).message); + } + } + function getEmpresaNome(id: Id<'empresas'>) { return empresas.find((e) => e._id === id)?.razao_social || 'Empresa não encontrada'; } + + function handleFileSelect(e: Event) { + const input = e.target as HTMLInputElement; + if (input.files && input.files.length > 0) { + mainPdfFile = input.files[0]; + } + } + + function handleAttachmentsSelect(e: Event) { + const input = e.target as HTMLInputElement; + if (input.files && input.files.length > 0) { + attachmentFiles = Array.from(input.files); + } + }
@@ -173,7 +252,12 @@ {ata.numero} {ata.numeroSei} - {getEmpresaNome(ata.empresaId)} + + {getEmpresaNome(ata.empresaId)} + {ata.dataInicio || '-'} a {ata.dataFim || '-'} @@ -209,7 +293,7 @@
-
+
+ +
+ + +
@@ -308,8 +405,8 @@
{#if filteredObjetos.length === 0}

Nenhum objeto encontrado.

@@ -334,9 +431,45 @@
{/if}
-

- Selecione os objetos que fazem parte desta Ata. -

+ +
+ + + + {#if attachments.length > 0} +
+ {#each attachments as doc (doc._id)} +
+ + {doc.nome} + + +
+ {/each} +
+ {/if} +
@@ -350,10 +483,10 @@ diff --git a/apps/web/src/routes/(dashboard)/pedidos/[id]/+page.svelte b/apps/web/src/routes/(dashboard)/pedidos/[id]/+page.svelte index 529cbe4..ae244a3 100644 --- a/apps/web/src/routes/(dashboard)/pedidos/[id]/+page.svelte +++ b/apps/web/src/routes/(dashboard)/pedidos/[id]/+page.svelte @@ -2,6 +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 { SvelteSet } from 'svelte/reactivity'; import { AlertTriangle, CheckCircle, @@ -15,18 +16,20 @@ X, XCircle } from 'lucide-svelte'; - import { page } from '$app/stores'; + import { goto } from '$app/navigation'; + import { resolve } from '$app/paths'; + import { page } from '$app/state'; import { maskCurrencyBRL } from '$lib/utils/masks'; - const pedidoId = $page.params.id as Id<'pedidos'>; + const pedidoId = $derived(page.params.id as Id<'pedidos'>); const client = useConvexClient(); // Reactive queries - const pedidoQuery = useQuery(api.pedidos.get, { id: pedidoId }); - const itemsQuery = useQuery(api.pedidos.getItems, { pedidoId }); - const historyQuery = useQuery(api.pedidos.getHistory, { pedidoId }); - const objetosQuery = useQuery(api.objetos.list, {}); - const acoesQuery = useQuery(api.acoes.list, {}); + const pedidoQuery = $derived.by(() => useQuery(api.pedidos.get, { id: pedidoId })); + const itemsQuery = $derived.by(() => useQuery(api.pedidos.getItems, { pedidoId })); + const historyQuery = $derived.by(() => useQuery(api.pedidos.getHistory, { pedidoId })); + const objetosQuery = $derived.by(() => useQuery(api.objetos.list, {})); + const acoesQuery = $derived.by(() => useQuery(api.acoes.list, {})); // Derived state let pedido = $derived(pedidoQuery.data); @@ -35,11 +38,7 @@ let objetos = $derived(objetosQuery.data || []); let acoes = $derived(acoesQuery.data || []); - type Modalidade = - | 'dispensa' - | 'inexgibilidade' - | 'adesao' - | 'consumo'; + type Modalidade = 'dispensa' | 'inexgibilidade' | 'adesao' | 'consumo'; type EditingItem = { valorEstimado: string; @@ -62,6 +61,28 @@ let editingItems = $state>({}); + // Seleção de itens para ações em lote + let selectedItemIds = new SvelteSet>(); + + function isItemSelected(itemId: Id<'objetoItems'>) { + return selectedItemIds.has(itemId); + } + + function toggleItemSelection(itemId: Id<'objetoItems'>) { + if (selectedItemIds.has(itemId)) { + selectedItemIds.delete(itemId); + } else { + selectedItemIds.add(itemId); + } + } + + function clearSelection() { + selectedItemIds.clear(); + } + + let selectedCount = $derived(selectedItemIds.size); + let hasSelection = $derived(selectedCount > 0); + // Garante que, para todos os itens existentes, as atas do respectivo objeto // sejam carregadas independentemente do formulário de criação. $effect(() => { @@ -145,7 +166,7 @@ if (hasAppliedPrefill) return; if (objetosQuery.isLoading || acoesQuery.isLoading) return; - const url = $page.url; + const url = page.url; const obj = url.searchParams.get('obj'); const qtdStr = url.searchParams.get('qtd'); const mod = url.searchParams.get('mod') as Modalidade | null; @@ -496,6 +517,61 @@ return `${entry.usuarioNome} - ${entry.acao}`; } } + + async function handleRemoveSelectedItems() { + if (!hasSelection) return; + if ( + !confirm( + selectedCount === 1 + ? 'Remover o item selecionado deste pedido?' + : `Remover os ${selectedCount} itens selecionados deste pedido?` + ) + ) + return; + + try { + const itemIds = Array.from(selectedItemIds) as Id<'objetoItems'>[]; + await client.mutation(api.pedidos.removeItemsBatch, { + itemIds + }); + clearSelection(); + } catch (e) { + alert('Erro ao remover itens selecionados: ' + (e as Error).message); + } + } + + let showSplitResultModal = $state(false); + let novoPedidoIdParaNavegar = $state | null>(null); + let quantidadeItensMovidos = $state(0); + + // Split Confirmation Modal State + let showSplitConfirmationModal = $state(false); + let newPedidoSei = $state(''); + + function handleSplitPedidoFromSelection() { + if (!hasSelection) return; + newPedidoSei = ''; + showSplitConfirmationModal = true; + } + + async function confirmSplitPedido() { + try { + const itemIds = Array.from(selectedItemIds) as Id<'objetoItems'>[]; + const novoPedidoId = await client.mutation(api.pedidos.splitPedido, { + pedidoId, + itemIds, + numeroSei: newPedidoSei.trim() || undefined + }); + + novoPedidoIdParaNavegar = novoPedidoId; + quantidadeItensMovidos = itemIds.length; + showSplitConfirmationModal = false; + showSplitResultModal = true; + clearSelection(); + } catch (e) { + alert('Erro ao dividir pedido: ' + (e as Error).message); + } + }
@@ -729,6 +805,47 @@ {/if}
+ {#if hasSelection} +
+
+ + {selectedCount} + + {selectedCount === 1 + ? '1 item selecionado' + : `${selectedCount} itens selecionados`} +
+
+ + + +
+
+ {/if} {#each groupedItems as group (group.name)}
Adicionado por: {group.name} @@ -736,9 +853,31 @@ + {#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes'} + + {/if} + Objeto {#each group.items as item (item._id)} - + + {#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes'} + + {/if}
+ { + const checked = e.currentTarget.checked; + for (const groupItem of group.items) { + if (checked) { + selectedItemIds.add(groupItem._id); + } else { + selectedItemIds.delete(groupItem._id); + } + } + }} + aria-label={`Selecionar todos os itens de ${group.name}`} + /> + Objeto
+ toggleItemSelection(item._id)} + aria-label={`Selecionar item ${getObjetoName(item.objetoId)}`} + /> + {getObjetoName(item.objetoId)} {#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes'} @@ -864,16 +1014,14 @@ {/each} + {:else if item.ataId} + {#each getAtasForObjeto(item.objetoId) as ata (ata._id)} + {#if ata._id === item.ataId} + Ata {ata.numero} + {/if} + {/each} {: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} @@ -971,9 +1119,9 @@
-
Valor Estimado (Unitário)
+
+ Valor Estimado (Unitário) +

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

@@ -1026,4 +1174,112 @@
{/if} + + {#if showSplitResultModal && novoPedidoIdParaNavegar} +
+
+ +

Novo pedido criado

+

+ {quantidadeItensMovidos === 1 + ? '1 item foi movido para um novo pedido em rascunho.' + : `${quantidadeItensMovidos} itens foram movidos para um novo pedido em rascunho.`} +

+

+ Os itens não foram copiados, e sim movidos deste pedido para o novo. +

+
+ + +
+
+
+ {/if} + + {#if showSplitConfirmationModal} +
+
+ +

Criar novo pedido

+

+ {selectedCount === 1 + ? 'Criar um novo pedido movendo o item selecionado para ele?' + : `Criar um novo pedido movendo os ${selectedCount} itens selecionados para ele?`} +

+ +
+ + +

+ Se deixado em branco, o novo pedido será criado sem número SEI. +

+
+ +
+ + +
+
+
+ {/if} diff --git a/apps/web/src/routes/(dashboard)/pedidos/novo/+page.svelte b/apps/web/src/routes/(dashboard)/pedidos/novo/+page.svelte index 50cd90b..032810c 100644 --- a/apps/web/src/routes/(dashboard)/pedidos/novo/+page.svelte +++ b/apps/web/src/routes/(dashboard)/pedidos/novo/+page.svelte @@ -37,6 +37,9 @@ let selectedItems = $state([]); let selectedObjetoIds = $derived(selectedItems.map((i) => i.objeto._id)); + let hasMixedModalidades = $derived( + new Set(selectedItems.map((i) => i.modalidade)).size > 1 + ); // Item configuration modal let showItemModal = $state(false); @@ -153,6 +156,36 @@ } } + function formatModalidade(modalidade: SelectedItem['modalidade']) { + switch (modalidade) { + case 'consumo': + return 'Consumo'; + case 'dispensa': + return 'Dispensa'; + case 'inexgibilidade': + return 'Inexigibilidade'; + case 'adesao': + return 'Adesão'; + default: + return modalidade; + } + } + + function getModalidadeBadgeClasses(modalidade: SelectedItem['modalidade']) { + switch (modalidade) { + case 'consumo': + return 'bg-blue-100 text-blue-800'; + case 'dispensa': + return 'bg-yellow-100 text-yellow-800'; + case 'inexgibilidade': + return 'bg-purple-100 text-purple-800'; + case 'adesao': + return 'bg-green-100 text-green-800'; + default: + return 'bg-gray-100 text-gray-800'; + } + } + function getAcaoNome(acaoId: Id<'acoes'> | undefined) { if (!acaoId) return '-'; const acao = acoes.find((a) => a._id === acaoId); @@ -172,7 +205,8 @@ .map((match) => { // Find name from selected items (might be multiple with same object, just pick one name) const item = selectedItems.find((p) => p.objeto._id === match.objetoId); - return `${item?.objeto.nome}: ${match.quantidade} un.`; + const modalidadeLabel = formatModalidade(match.modalidade); + return `${item?.objeto.nome} (${modalidadeLabel}): ${match.quantidade} un.`; }) .join(', '); @@ -226,6 +260,8 @@ checking = true; try { + // Importante: ação (acaoId) NÃO entra no filtro de similaridade. + // O filtro considera apenas combinação de objeto + modalidade. const itensFiltro = selectedItems.length > 0 ? selectedItems.map((item) => ({ @@ -255,6 +291,11 @@ async function handleSubmit(e: Event) { e.preventDefault(); + if (hasMixedModalidades) { + error = + 'Não é possível criar o pedido com itens de modalidades diferentes. Ajuste os itens antes de continuar.'; + return; + } creating = true; error = null; try { @@ -369,32 +410,44 @@
{#each selectedItems as item, index (index)}
-
-
-
+
+
+

{item.objeto.nome}

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

Modalidades diferentes detectadas

+

+ Não é possível criar o pedido com itens de modalidades diferentes. Ajuste os itens para + usar uma única modalidade. +

+
+ {/if} + {#if warning}
{#each existingPedidos as pedido (pedido._id)}
  • -
    -
    +
    +

    Pedido {pedido.numeroSei || 'sem número SEI'} — {formatStatus(pedido.status)}

    + {#if getFirstMatchingSelectedItem(pedido)} + {#key pedido._id} + {#if getFirstMatchingSelectedItem(pedido)} + + Modalidade:{' '} + {formatModalidade(getFirstMatchingSelectedItem(pedido).modalidade)} + + {/if} + {/key} + {/if} {#if getMatchingInfo(pedido)}

    {getMatchingInfo(pedido)} @@ -477,7 +556,7 @@