From ef9dbedb34d0e08e38470acc058cea67fb86bd49 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Sun, 21 Dec 2025 21:01:23 -0300 Subject: [PATCH] feat: implement material deletion functionality in 'Almoxarifado', including error handling for related stock movements and requests, and enhance user experience with confirmation modals --- .../almoxarifado/ImageUpload.svelte | 5 +- .../almoxarifado/materiais/+page.svelte | 290 +++++++++- .../materiais/[materialId]/+page.svelte | 33 +- .../[materialId]/editar/+page.svelte | 1 + .../materiais/cadastro/+page.svelte | 530 ++++++++++++------ packages/backend/convex/almoxarifado.ts | 59 ++ 6 files changed, 720 insertions(+), 198 deletions(-) diff --git a/apps/web/src/lib/components/almoxarifado/ImageUpload.svelte b/apps/web/src/lib/components/almoxarifado/ImageUpload.svelte index d2b2d02..f62d939 100644 --- a/apps/web/src/lib/components/almoxarifado/ImageUpload.svelte +++ b/apps/web/src/lib/components/almoxarifado/ImageUpload.svelte @@ -280,9 +280,8 @@ // Sincronizar preview com value sempre que value mudar $effect(() => { - if (value !== preview) { - preview = value; - } + // Sempre sincronizar quando value mudar + preview = value; }); diff --git a/apps/web/src/routes/(dashboard)/almoxarifado/materiais/+page.svelte b/apps/web/src/routes/(dashboard)/almoxarifado/materiais/+page.svelte index 187df92..4e2c35f 100644 --- a/apps/web/src/routes/(dashboard)/almoxarifado/materiais/+page.svelte +++ b/apps/web/src/routes/(dashboard)/almoxarifado/materiais/+page.svelte @@ -4,12 +4,15 @@ import { useConvexClient, useQuery } from 'convex-svelte'; import { goto } from '$app/navigation'; import { resolve } from '$app/paths'; - import { Package, Plus, Search, Edit, Eye, AlertTriangle } from 'lucide-svelte'; + import { Package, Plus, Search, Edit, Eye, AlertTriangle, Trash2, Info, X } from 'lucide-svelte'; import BarcodeScanner from '$lib/components/almoxarifado/BarcodeScanner.svelte'; const client = useConvexClient(); - let materiais = $state>>([]); + // Usar useQuery para atualização automática + const materiaisQuery = useQuery(api.almoxarifado.listarMateriais, {}); + + let materiais = $derived(materiaisQuery?.data ?? []); let filtered = $state>>([]); let filtroBusca = $state(''); let filtroCategoria = $state(''); @@ -19,6 +22,11 @@ let materialEncontrado = $state | null>(null); let buscandoPorCodigoBarras = $state(false); let buscaTimeout: ReturnType | null = null; + let materialParaExcluir = $state | null>(null); + let excluindo = $state(false); + let notice = $state<{ kind: 'success' | 'error'; text: string } | null>(null); + let erroExclusao = $state<{ titulo: string; mensagem: string; tipo: 'movimentacoes' | 'requisicoes' | 'outro' } | null>(null); + let desativando = $state(false); const categorias = $derived( Array.from(new Set(materiais.map((m) => m.categoria).filter(Boolean))).sort() @@ -67,17 +75,11 @@ } } - async function load() { - const data = await client.query(api.almoxarifado.listarMateriais, {}); - materiais = data ?? []; - applyFilters(); - } - - $effect(() => { - load(); - }); - + // Aplicar filtros sempre que materiais ou filtros mudarem $effect(() => { + // Acessar materiais para criar dependência reativa + const _ = materiais; + // Aplicar filtros applyFilters(); }); @@ -126,6 +128,114 @@ } }; }); + + function abrirModalExclusao(material: Doc<'materiais'>) { + materialParaExcluir = material; + (document.getElementById('modal-excluir-material') as HTMLDialogElement)?.showModal(); + } + + function fecharModalExclusao() { + materialParaExcluir = null; + (document.getElementById('modal-excluir-material') as HTMLDialogElement)?.close(); + } + + async function confirmarExclusao() { + if (!materialParaExcluir) return; + + const materialId = materialParaExcluir._id; + const materialNome = materialParaExcluir.nome; + + try { + excluindo = true; + + // Remover item da lista localmente imediatamente (otimistic update) + filtered = filtered.filter((m) => m._id !== materialId); + + await client.mutation(api.almoxarifado.deletarMaterial, { + id: materialId + }); + + fecharModalExclusao(); + notice = { + kind: 'success', + text: `Material "${materialNome}" excluído com sucesso!` + }; + + // Limpar notificação após 5 segundos + setTimeout(() => { + notice = null; + }, 5000); + } catch (error: unknown) { + // Se houver erro, recarregar a lista para garantir consistência + applyFilters(); + + const message = error instanceof Error ? error.message : 'Erro ao excluir material'; + + // Determinar tipo de erro e criar mensagem mais clara + let tipo: 'movimentacoes' | 'requisicoes' | 'outro' = 'outro'; + let titulo = 'Não foi possível excluir o material'; + let mensagem = message; + + const messageLower = message.toLowerCase(); + + if (messageLower.includes('movimenta') || messageLower.includes('movimentação')) { + tipo = 'movimentacoes'; + titulo = 'Material possui movimentações de estoque'; + mensagem = 'Este material possui movimentações de estoque registradas e não pode ser excluído para manter o histórico. Você pode desativá-lo ao invés de excluir.'; + } else if (messageLower.includes('requisi') || messageLower.includes('requisição')) { + tipo = 'requisicoes'; + titulo = 'Material possui requisições registradas'; + mensagem = 'Este material possui requisições registradas e não pode ser excluído para manter o histórico. Você pode desativá-lo ao invés de excluir.'; + } + + // Exibir modal de erro + erroExclusao = { titulo, mensagem, tipo }; + fecharModalExclusao(); + (document.getElementById('modal-erro-exclusao') as HTMLDialogElement)?.showModal(); + } finally { + excluindo = false; + } + } + + function fecharModalErro() { + erroExclusao = null; + (document.getElementById('modal-erro-exclusao') as HTMLDialogElement)?.close(); + } + + async function desativarMaterial() { + if (!materialParaExcluir || !erroExclusao) return; + + const materialId = materialParaExcluir._id; + const materialNome = materialParaExcluir.nome; + + try { + desativando = true; + + await client.mutation(api.almoxarifado.editarMaterial, { + id: materialId, + ativo: false + }); + + fecharModalErro(); + notice = { + kind: 'success', + text: `Material "${materialNome}" desativado com sucesso!` + }; + + // Limpar notificação após 5 segundos + setTimeout(() => { + notice = null; + }, 5000); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Erro ao desativar material'; + notice = { kind: 'error', text: message }; + setTimeout(() => { + notice = null; + }, 5000); + } finally { + desativando = false; + } + }
@@ -158,6 +268,13 @@ + + {#if notice} +
+ {notice.text} +
+ {/if} +
@@ -314,6 +431,13 @@ > +
@@ -332,6 +456,148 @@ {/if}
+ + + + + + + + + {#if erroExclusao} + + + + + {/if}
diff --git a/apps/web/src/routes/(dashboard)/almoxarifado/materiais/[materialId]/+page.svelte b/apps/web/src/routes/(dashboard)/almoxarifado/materiais/[materialId]/+page.svelte index 181a794..227bb4a 100644 --- a/apps/web/src/routes/(dashboard)/almoxarifado/materiais/[materialId]/+page.svelte +++ b/apps/web/src/routes/(dashboard)/almoxarifado/materiais/[materialId]/+page.svelte @@ -103,9 +103,30 @@ -
+
+ + {#if material.imagemBase64} +
+
+

+
+ +
+ Imagem do Produto +

+
+ {material.nome} +
+
+
+ {/if} + -
+

@@ -118,6 +139,12 @@

{material.codigo}

+ {#if material.codigoBarras} +
+ +

{material.codigoBarras}

+
+ {/if}

{material.nome}

@@ -159,7 +186,7 @@
-
+

diff --git a/apps/web/src/routes/(dashboard)/almoxarifado/materiais/[materialId]/editar/+page.svelte b/apps/web/src/routes/(dashboard)/almoxarifado/materiais/[materialId]/editar/+page.svelte index e1917ab..14bf7f9 100644 --- a/apps/web/src/routes/(dashboard)/almoxarifado/materiais/[materialId]/editar/+page.svelte +++ b/apps/web/src/routes/(dashboard)/almoxarifado/materiais/[materialId]/editar/+page.svelte @@ -6,6 +6,7 @@ import { resolve } from '$app/paths'; import { page } from '$app/stores'; import { Package, Save, ArrowLeft } from 'lucide-svelte'; + import ImageUpload from '$lib/components/almoxarifado/ImageUpload.svelte'; const client = useConvexClient(); diff --git a/apps/web/src/routes/(dashboard)/almoxarifado/materiais/cadastro/+page.svelte b/apps/web/src/routes/(dashboard)/almoxarifado/materiais/cadastro/+page.svelte index cc4c658..07919d5 100644 --- a/apps/web/src/routes/(dashboard)/almoxarifado/materiais/cadastro/+page.svelte +++ b/apps/web/src/routes/(dashboard)/almoxarifado/materiais/cadastro/+page.svelte @@ -3,6 +3,7 @@ import { useConvexClient } from 'convex-svelte'; import { goto } from '$app/navigation'; import { resolve } from '$app/paths'; + import { tick } from 'svelte'; import { Package, Save, ArrowLeft, Check, X, ExternalLink, Loader2, AlertCircle, Info } from 'lucide-svelte'; import BarcodeScanner from '$lib/components/almoxarifado/BarcodeScanner.svelte'; import ImageUpload from '$lib/components/almoxarifado/ImageUpload.svelte'; @@ -132,28 +133,47 @@ // Carregar imagem se disponível (aguardar conversão para garantir que seja salva e exibida) if (dadosExternos.imagemUrl) { let imagemParaSalvar: string | null = null; + const imagemUrlAtual = dadosExternos.imagemUrl; - if (dadosExternos.imagemUrl.startsWith('data:')) { - // Já está em base64 - imagemParaSalvar = dadosExternos.imagemUrl; + // Verificar se já está em base64 (carregada em background) + if (imagemUrlAtual.startsWith('data:')) { + // Já está em base64, usar diretamente + imagemParaSalvar = imagemUrlAtual; + console.log('Imagem já carregada em background, usando diretamente'); } else { - // Aguardar carregar a imagem da URL antes de continuar - try { - const imagemBase64Carregada = await carregarImagemDeUrl(dadosExternos.imagemUrl); - if (imagemBase64Carregada) { - imagemParaSalvar = imagemBase64Carregada; - console.log('Imagem carregada e convertida para base64 com sucesso'); - } else { - console.warn('Não foi possível carregar a imagem da URL:', dadosExternos.imagemUrl); + // Ainda é uma URL, aguardar um pouco para ver se o carregamento em background terminou + // Se após 500ms ainda for URL, carregar agora + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Verificar novamente se foi carregada em background + if (dadosExternos.imagemUrl && dadosExternos.imagemUrl.startsWith('data:')) { + imagemParaSalvar = dadosExternos.imagemUrl; + console.log('Imagem foi carregada em background durante a espera'); + } else { + // Ainda é URL, carregar agora + console.log('Carregando imagem da URL:', imagemUrlAtual); + try { + const imagemBase64Carregada = await carregarImagemDeUrl(imagemUrlAtual); + if (imagemBase64Carregada) { + imagemParaSalvar = imagemBase64Carregada; + console.log('Imagem carregada e convertida para base64 com sucesso'); + } else { + console.warn('Não foi possível carregar a imagem da URL:', imagemUrlAtual); + } + } catch (err) { + console.error('Erro ao carregar imagem:', err); } - } catch (err) { - console.error('Erro ao carregar imagem:', err); } } // Atribuir a imagem após o carregamento completo if (imagemParaSalvar) { imagemBase64 = imagemParaSalvar; + // Aguardar o tick para garantir que o componente ImageUpload detecte a mudança + await tick(); + console.log('Imagem atribuída ao campo imagemBase64, tamanho:', imagemParaSalvar.length, 'caracteres'); + } else { + console.warn('Nenhuma imagem disponível para salvar'); } } @@ -237,14 +257,19 @@ // Tentar carregar imagem se disponível (em background, não bloquear) if (infoExterna.imagemUrl && !infoExterna.imagemUrl.startsWith('data:')) { + // Carregar imagem em background carregarImagemDeUrl(infoExterna.imagemUrl) .then((imagemBase64Carregada) => { if (imagemBase64Carregada) { + // Atualizar dadosExternos com a imagem em base64 dadosExternos = { ...dadosExternos, imagemUrl: imagemBase64Carregada }; + console.log('Imagem carregada em background e atualizada em dadosExternos'); + } else { + console.warn('Falha ao carregar imagem em background'); } }) .catch((err) => { - console.error('Erro ao carregar imagem:', err); + console.error('Erro ao carregar imagem em background:', err); // Manter URL original se falhar }); } @@ -373,6 +398,35 @@ loading = false; } } + + // Handler para tecla Escape + function handleEscapeKey(event: KeyboardEvent) { + if (event.key === 'Escape') { + if (modalDadosExternos && !carregandoImagemExterna) { + descartarDadosExternos(); + } else if (modalProdutoNaoEncontrado) { + fecharModalProdutoNaoEncontrado(); + } + } + } + + // Gerenciar scroll do body quando modais estão abertos + $effect(() => { + const temModalAberto = modalDadosExternos || modalBuscando || modalProdutoNaoEncontrado; + + if (temModalAberto) { + document.body.classList.add('modal-open'); + document.addEventListener('keydown', handleEscapeKey); + } else { + document.body.classList.remove('modal-open'); + document.removeEventListener('keydown', handleEscapeKey); + } + + return () => { + document.body.classList.remove('modal-open'); + document.removeEventListener('keydown', handleEscapeKey); + }; + });
@@ -646,60 +700,72 @@ {#if modalDadosExternos && dadosExternos}
+ + diff --git a/packages/backend/convex/almoxarifado.ts b/packages/backend/convex/almoxarifado.ts index 7becb8a..4cf54eb 100644 --- a/packages/backend/convex/almoxarifado.ts +++ b/packages/backend/convex/almoxarifado.ts @@ -1258,6 +1258,65 @@ export const ignorarAlerta = mutation({ } }); +export const deletarMaterial = mutation({ + args: { + id: v.id('materiais') + }, + handler: async (ctx, args) => { + await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { + recurso: 'almoxarifado', + acao: 'deletar_material' + }); + + const material = await ctx.db.get(args.id); + if (!material) throw new Error('Material não encontrado'); + + // Verificar se há movimentações relacionadas + const movimentacoes = await ctx.db + .query('movimentacoesEstoque') + .withIndex('by_materialId', (q) => q.eq('materialId', args.id)) + .first(); + + if (movimentacoes) { + throw new Error('Não é possível excluir material com movimentações de estoque registradas. O material possui histórico de movimentações e deve ser desativado ao invés de excluído.'); + } + + // Verificar se há requisições relacionadas + const requisicoes = await ctx.db + .query('requisicaoItens') + .withIndex('by_materialId', (q) => q.eq('materialId', args.id)) + .first(); + + if (requisicoes) { + throw new Error('Não é possível excluir material com requisições registradas. O material possui requisições associadas e deve ser desativado ao invés de excluído.'); + } + + // Verificar se há alertas relacionados + const alertas = await ctx.db + .query('alertasEstoque') + .withIndex('by_materialId', (q) => q.eq('materialId', args.id)) + .first(); + + if (alertas) { + // Deletar alertas relacionados + const todosAlertas = await ctx.db + .query('alertasEstoque') + .withIndex('by_materialId', (q) => q.eq('materialId', args.id)) + .collect(); + + for (const alerta of todosAlertas) { + await ctx.db.delete(alerta._id); + } + } + + // Registrar histórico antes de deletar + await registrarHistorico(ctx, 'material', args.id.toString(), 'exclusao', material, undefined); + + // Deletar o material + await ctx.db.delete(args.id); + } +}); + // ========== INTERNAL ACTIONS ========== export const registrarHistoricoAlteracao = internalMutation({