feat: implement material deletion functionality in 'Almoxarifado', including error handling for related stock movements and requests, and enhance user experience with confirmation modals

This commit is contained in:
2025-12-21 21:01:23 -03:00
parent 639f7c6467
commit ef9dbedb34
6 changed files with 720 additions and 198 deletions

View File

@@ -280,9 +280,8 @@
// Sincronizar preview com value sempre que value mudar // Sincronizar preview com value sempre que value mudar
$effect(() => { $effect(() => {
if (value !== preview) { // Sempre sincronizar quando value mudar
preview = value; preview = value;
}
}); });

View File

@@ -4,12 +4,15 @@
import { useConvexClient, useQuery } from 'convex-svelte'; import { useConvexClient, useQuery } from 'convex-svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { resolve } from '$app/paths'; 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'; import BarcodeScanner from '$lib/components/almoxarifado/BarcodeScanner.svelte';
const client = useConvexClient(); const client = useConvexClient();
let materiais = $state<Array<Doc<'materiais'>>>([]); // Usar useQuery para atualização automática
const materiaisQuery = useQuery(api.almoxarifado.listarMateriais, {});
let materiais = $derived(materiaisQuery?.data ?? []);
let filtered = $state<Array<Doc<'materiais'>>>([]); let filtered = $state<Array<Doc<'materiais'>>>([]);
let filtroBusca = $state(''); let filtroBusca = $state('');
let filtroCategoria = $state(''); let filtroCategoria = $state('');
@@ -19,6 +22,11 @@
let materialEncontrado = $state<Doc<'materiais'> | null>(null); let materialEncontrado = $state<Doc<'materiais'> | null>(null);
let buscandoPorCodigoBarras = $state(false); let buscandoPorCodigoBarras = $state(false);
let buscaTimeout: ReturnType<typeof setTimeout> | null = null; let buscaTimeout: ReturnType<typeof setTimeout> | null = null;
let materialParaExcluir = $state<Doc<'materiais'> | 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( const categorias = $derived(
Array.from(new Set(materiais.map((m) => m.categoria).filter(Boolean))).sort() Array.from(new Set(materiais.map((m) => m.categoria).filter(Boolean))).sort()
@@ -67,17 +75,11 @@
} }
} }
async function load() { // Aplicar filtros sempre que materiais ou filtros mudarem
const data = await client.query(api.almoxarifado.listarMateriais, {});
materiais = data ?? [];
applyFilters();
}
$effect(() => {
load();
});
$effect(() => { $effect(() => {
// Acessar materiais para criar dependência reativa
const _ = materiais;
// Aplicar filtros
applyFilters(); 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;
}
}
</script> </script>
<main class="container mx-auto px-4 py-6"> <main class="container mx-auto px-4 py-6">
@@ -158,6 +268,13 @@
</div> </div>
</div> </div>
<!-- Notificações -->
{#if notice}
<div class="alert alert-{notice.kind} mb-6 shadow-lg">
<span>{notice.text}</span>
</div>
{/if}
<!-- Filtros --> <!-- Filtros -->
<div class="card bg-base-100 border border-base-300 mb-6 shadow-xl"> <div class="card bg-base-100 border border-base-300 mb-6 shadow-xl">
<div class="card-body"> <div class="card-body">
@@ -314,6 +431,13 @@
> >
<Edit class="h-4 w-4" /> <Edit class="h-4 w-4" />
</button> </button>
<button
class="btn btn-sm btn-ghost hover:btn-error"
title="Excluir"
onclick={() => abrirModalExclusao(material)}
>
<Trash2 class="h-4 w-4" />
</button>
</div> </div>
</td> </td>
</tr> </tr>
@@ -332,6 +456,148 @@
{/if} {/if}
</div> </div>
</div> </div>
<!-- Modal de Confirmação de Exclusão -->
<dialog id="modal-excluir-material" class="modal">
<div class="modal-box">
<h3 class="text-lg font-bold mb-4">Confirmar Exclusão</h3>
{#if materialParaExcluir}
<div class="space-y-4">
<div class="alert alert-warning">
<AlertTriangle class="h-5 w-5" />
<div>
<p class="font-semibold">Atenção!</p>
<p class="text-sm">
Esta ação não pode ser desfeita. O material será permanentemente excluído.
</p>
</div>
</div>
<div class="bg-base-200 rounded-lg p-4">
<p class="text-sm text-base-content/70 mb-2">Material a ser excluído:</p>
<p class="font-semibold text-base-content">{materialParaExcluir.nome}</p>
<p class="text-sm text-base-content/60 mt-1">
Código: <span class="font-mono">{materialParaExcluir.codigo}</span>
</p>
{#if materialParaExcluir.estoqueAtual > 0}
<div class="mt-2 alert alert-info py-2">
<p class="text-xs">
⚠️ Este material possui <strong>{materialParaExcluir.estoqueAtual}</strong> unidades em estoque.
</p>
</div>
{/if}
</div>
</div>
{/if}
<div class="modal-action">
<button
class="btn btn-ghost"
onclick={fecharModalExclusao}
disabled={excluindo}
>
Cancelar
</button>
<button
class="btn btn-error"
onclick={confirmarExclusao}
disabled={excluindo}
>
{#if excluindo}
<span class="loading loading-spinner loading-sm"></span>
Excluindo...
{:else}
<Trash2 class="h-4 w-4" />
Excluir
{/if}
</button>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button onclick={fecharModalExclusao}>fechar</button>
</form>
</dialog>
<!-- Modal de Erro na Exclusão -->
{#if erroExclusao}
<dialog id="modal-erro-exclusao" class="modal modal-open">
<div class="modal-box max-w-2xl">
<div class="flex items-center gap-4 mb-6">
<div class="rounded-2xl bg-error/20 p-3">
<AlertTriangle class="h-8 w-8 text-error" strokeWidth={2.5} />
</div>
<div>
<h3 class="text-2xl font-bold text-base-content">{erroExclusao.titulo}</h3>
<p class="text-base-content/70 mt-1">Não foi possível excluir o material</p>
</div>
</div>
<div class="space-y-4 mb-6">
<div class="alert alert-error border-error/30 bg-error/10">
<AlertTriangle class="h-5 w-5 shrink-0 text-error" />
<div class="flex-1">
<p class="font-semibold text-base-content">Motivo do bloqueio</p>
<p class="text-sm text-base-content/90 mt-1">{erroExclusao.mensagem}</p>
</div>
</div>
{#if materialParaExcluir}
<div class="bg-base-200 rounded-lg p-4 border border-base-300">
<p class="text-sm text-base-content/70 mb-2 font-semibold">Material:</p>
<p class="font-bold text-lg text-base-content">{materialParaExcluir.nome}</p>
<p class="text-sm text-base-content/60 mt-1">
Código: <span class="font-mono font-semibold">{materialParaExcluir.codigo}</span>
</p>
</div>
{/if}
<div class="bg-info/10 border border-info/30 rounded-lg p-4">
<div class="flex gap-3">
<Info class="h-5 w-5 text-info shrink-0 mt-0.5" />
<div>
<p class="font-semibold text-base-content mb-1">Solução recomendada</p>
<p class="text-sm text-base-content/80">
{#if erroExclusao.tipo === 'movimentacoes'}
O material possui histórico de movimentações de estoque. Para manter a integridade dos dados históricos, recomendamos <strong>desativar</strong> o material ao invés de excluí-lo. Um material desativado não aparecerá nas listagens ativas, mas seu histórico será preservado.
{:else if erroExclusao.tipo === 'requisicoes'}
O material possui requisições registradas. Para manter a integridade dos dados históricos, recomendamos <strong>desativar</strong> o material ao invés de excluí-lo. Um material desativado não aparecerá nas listagens ativas, mas seu histórico será preservado.
{:else}
Recomendamos verificar as dependências do material antes de tentar excluí-lo novamente.
{/if}
</p>
</div>
</div>
</div>
</div>
<div class="modal-action gap-3">
<button
class="btn btn-ghost"
onclick={fecharModalErro}
disabled={desativando}
>
Entendi
</button>
{#if erroExclusao.tipo === 'movimentacoes' || erroExclusao.tipo === 'requisicoes'}
<button
class="btn btn-warning gap-2"
onclick={desativarMaterial}
disabled={desativando}
>
{#if desativando}
<span class="loading loading-spinner loading-sm"></span>
Desativando...
{:else}
<X class="h-4 w-4" />
Desativar Material
{/if}
</button>
{/if}
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button onclick={fecharModalErro}>fechar</button>
</form>
</dialog>
{/if}
</main> </main>

View File

@@ -103,9 +103,30 @@
</div> </div>
<!-- Informações Principais --> <!-- Informações Principais -->
<div class="grid grid-cols-1 gap-6 md:grid-cols-2"> <div class="grid grid-cols-1 gap-6 {material.imagemBase64 ? 'md:grid-cols-3' : 'md:grid-cols-2'}">
<!-- Card: Imagem do Produto -->
{#if material.imagemBase64}
<div class="card bg-base-100 border border-base-300 shadow-xl md:col-span-1">
<div class="card-body">
<h2 class="card-title mb-6 text-xl">
<div class="rounded-lg bg-primary/20 p-2">
<Package class="h-6 w-6 text-primary" />
</div>
Imagem do Produto
</h2>
<div class="flex justify-center">
<img
src={material.imagemBase64}
alt={material.nome}
class="max-h-96 w-full rounded-lg border border-base-300 object-contain bg-base-200 p-4 shadow-inner"
/>
</div>
</div>
</div>
{/if}
<!-- Card: Informações Básicas --> <!-- Card: Informações Básicas -->
<div class="card bg-base-100 border border-base-300 shadow-xl"> <div class="card bg-base-100 border border-base-300 shadow-xl {material.imagemBase64 ? 'md:col-span-1' : ''}">
<div class="card-body"> <div class="card-body">
<h2 class="card-title mb-6 text-xl"> <h2 class="card-title mb-6 text-xl">
<div class="rounded-lg bg-primary/20 p-2"> <div class="rounded-lg bg-primary/20 p-2">
@@ -118,6 +139,12 @@
<label class="text-sm font-semibold text-base-content/60">Código</label> <label class="text-sm font-semibold text-base-content/60">Código</label>
<p class="font-mono text-lg font-bold">{material.codigo}</p> <p class="font-mono text-lg font-bold">{material.codigo}</p>
</div> </div>
{#if material.codigoBarras}
<div>
<label class="text-sm font-semibold text-base-content/60">Código de Barras</label>
<p class="font-mono text-base">{material.codigoBarras}</p>
</div>
{/if}
<div> <div>
<label class="text-sm font-semibold text-base-content/60">Nome</label> <label class="text-sm font-semibold text-base-content/60">Nome</label>
<p class="text-lg">{material.nome}</p> <p class="text-lg">{material.nome}</p>
@@ -159,7 +186,7 @@
</div> </div>
<!-- Card: Estoque --> <!-- Card: Estoque -->
<div class="card bg-base-100 border border-base-300 shadow-xl"> <div class="card bg-base-100 border border-base-300 shadow-xl {material.imagemBase64 ? 'md:col-span-1' : ''}">
<div class="card-body"> <div class="card-body">
<h2 class="card-title mb-6 text-xl"> <h2 class="card-title mb-6 text-xl">
<div class="rounded-lg bg-info/20 p-2"> <div class="rounded-lg bg-info/20 p-2">

View File

@@ -6,6 +6,7 @@
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { Package, Save, ArrowLeft } from 'lucide-svelte'; import { Package, Save, ArrowLeft } from 'lucide-svelte';
import ImageUpload from '$lib/components/almoxarifado/ImageUpload.svelte';
const client = useConvexClient(); const client = useConvexClient();

View File

@@ -3,6 +3,7 @@
import { useConvexClient } from 'convex-svelte'; import { useConvexClient } from 'convex-svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { tick } from 'svelte';
import { Package, Save, ArrowLeft, Check, X, ExternalLink, Loader2, AlertCircle, Info } from 'lucide-svelte'; import { Package, Save, ArrowLeft, Check, X, ExternalLink, Loader2, AlertCircle, Info } from 'lucide-svelte';
import BarcodeScanner from '$lib/components/almoxarifado/BarcodeScanner.svelte'; import BarcodeScanner from '$lib/components/almoxarifado/BarcodeScanner.svelte';
import ImageUpload from '$lib/components/almoxarifado/ImageUpload.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) // Carregar imagem se disponível (aguardar conversão para garantir que seja salva e exibida)
if (dadosExternos.imagemUrl) { if (dadosExternos.imagemUrl) {
let imagemParaSalvar: string | null = null; let imagemParaSalvar: string | null = null;
const imagemUrlAtual = dadosExternos.imagemUrl;
if (dadosExternos.imagemUrl.startsWith('data:')) { // Verificar se já está em base64 (carregada em background)
// Já está em base64 if (imagemUrlAtual.startsWith('data:')) {
imagemParaSalvar = dadosExternos.imagemUrl; // Já está em base64, usar diretamente
imagemParaSalvar = imagemUrlAtual;
console.log('Imagem já carregada em background, usando diretamente');
} else { } else {
// Aguardar carregar a imagem da URL antes de continuar // 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 { try {
const imagemBase64Carregada = await carregarImagemDeUrl(dadosExternos.imagemUrl); const imagemBase64Carregada = await carregarImagemDeUrl(imagemUrlAtual);
if (imagemBase64Carregada) { if (imagemBase64Carregada) {
imagemParaSalvar = imagemBase64Carregada; imagemParaSalvar = imagemBase64Carregada;
console.log('Imagem carregada e convertida para base64 com sucesso'); console.log('Imagem carregada e convertida para base64 com sucesso');
} else { } else {
console.warn('Não foi possível carregar a imagem da URL:', dadosExternos.imagemUrl); console.warn('Não foi possível carregar a imagem da URL:', imagemUrlAtual);
} }
} catch (err) { } catch (err) {
console.error('Erro ao carregar imagem:', err); console.error('Erro ao carregar imagem:', err);
} }
} }
}
// Atribuir a imagem após o carregamento completo // Atribuir a imagem após o carregamento completo
if (imagemParaSalvar) { if (imagemParaSalvar) {
imagemBase64 = 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) // Tentar carregar imagem se disponível (em background, não bloquear)
if (infoExterna.imagemUrl && !infoExterna.imagemUrl.startsWith('data:')) { if (infoExterna.imagemUrl && !infoExterna.imagemUrl.startsWith('data:')) {
// Carregar imagem em background
carregarImagemDeUrl(infoExterna.imagemUrl) carregarImagemDeUrl(infoExterna.imagemUrl)
.then((imagemBase64Carregada) => { .then((imagemBase64Carregada) => {
if (imagemBase64Carregada) { if (imagemBase64Carregada) {
// Atualizar dadosExternos com a imagem em base64
dadosExternos = { ...dadosExternos, imagemUrl: imagemBase64Carregada }; 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) => { .catch((err) => {
console.error('Erro ao carregar imagem:', err); console.error('Erro ao carregar imagem em background:', err);
// Manter URL original se falhar // Manter URL original se falhar
}); });
} }
@@ -373,6 +398,35 @@
loading = false; 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);
};
});
</script> </script>
<main class="container mx-auto px-4 py-6"> <main class="container mx-auto px-4 py-6">
@@ -646,27 +700,39 @@
<!-- Modal de Dados Externos Encontrados --> <!-- Modal de Dados Externos Encontrados -->
{#if modalDadosExternos && dadosExternos} {#if modalDadosExternos && dadosExternos}
<div <div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" class="pointer-events-none fixed inset-0 z-50"
style="animation: fadeIn 0.2s ease-out;"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="modal-dados-externos-title" aria-labelledby="modal-dados-externos-title"
aria-describedby="modal-dados-externos-description"
> >
<!-- Backdrop -->
<div <div
class="bg-base-100 mx-4 w-full max-w-2xl transform rounded-2xl shadow-2xl transition-all" class="pointer-events-auto absolute inset-0 bg-black/50 backdrop-blur-md transition-opacity duration-300"
onclick={descartarDadosExternos}
aria-hidden="true"
></div>
<!-- Modal Box -->
<div
class="pointer-events-auto absolute left-1/2 top-1/2 z-10 flex max-h-[92vh] w-full max-w-3xl transform -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-3xl bg-base-100 shadow-2xl transition-all duration-300"
style="animation: slideUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);"
onclick={(e) => e.stopPropagation()} onclick={(e) => e.stopPropagation()}
> >
<!-- Header --> <!-- Header -->
<div class="border-base-300 from-primary/10 to-secondary/10 border-b bg-linear-to-r p-6"> <div
<div class="flex items-start justify-between"> class="border-base-300 from-primary/10 via-primary/5 to-transparent flex flex-shrink-0 items-center justify-between border-b bg-gradient-to-r px-6 py-5"
<div class="flex items-center gap-3"> >
<div class="rounded-2xl bg-primary/20 p-3"> <div class="flex items-center gap-4">
<div class="rounded-2xl bg-primary/20 p-3 shadow-lg">
<ExternalLink class="h-6 w-6 text-primary" strokeWidth={2.5} /> <ExternalLink class="h-6 w-6 text-primary" strokeWidth={2.5} />
</div> </div>
<div> <div class="flex-1">
<h3 id="modal-dados-externos-title" class="text-xl font-bold"> <h3 id="modal-dados-externos-title" class="text-base-content text-2xl font-bold">
Produto encontrado externamente Produto encontrado externamente
</h3> </h3>
<p class="text-base-content/70 text-sm mt-1"> <p id="modal-dados-externos-description" class="text-base-content/70 mt-1 text-sm">
{#if dadosExternos?.fonte} {#if dadosExternos?.fonte}
Dados encontrados em: <span class="font-semibold text-primary">{dadosExternos.fonte}</span> Dados encontrados em: <span class="font-semibold text-primary">{dadosExternos.fonte}</span>
{:else} {:else}
@@ -677,29 +743,29 @@
</div> </div>
<button <button
type="button" type="button"
class="btn btn-sm btn-circle btn-ghost" class="btn btn-sm btn-circle btn-ghost hover:bg-base-300 transition-colors"
onclick={descartarDadosExternos} onclick={descartarDadosExternos}
aria-label="Fechar" aria-label="Fechar modal"
> >
<X class="h-5 w-5" /> <X class="h-5 w-5" />
</button> </button>
</div> </div>
</div>
<!-- Content --> <!-- Content -->
<div class="max-h-[60vh] overflow-y-auto p-6"> <div class="modal-scroll flex-1 overflow-y-auto px-6 py-6">
<div class="grid grid-cols-1 gap-6 md:grid-cols-2"> <div class="space-y-6">
<!-- Imagem do Produto --> <!-- Imagem do Produto -->
{#if dadosExternos.imagemUrl} {#if dadosExternos.imagemUrl}
<div class="md:col-span-2"> <div class="form-control">
<label class="label"> <label class="label pb-3">
<span class="label-text font-semibold">Imagem do Produto</span> <span class="label-text text-base font-semibold">Imagem do Produto</span>
</label> </label>
<div class="flex justify-center"> <div class="flex justify-center rounded-xl border-2 border-base-300 bg-gradient-to-br from-base-200 to-base-300 p-6 shadow-inner">
<img <img
src={dadosExternos.imagemUrl} src={dadosExternos.imagemUrl}
alt="Imagem do produto" alt="Imagem do produto encontrado"
class="max-h-64 rounded-lg border border-base-300" class="max-h-72 max-w-full rounded-lg object-contain shadow-lg"
loading="lazy"
onerror={(e) => { onerror={(e) => {
(e.target as HTMLImageElement).style.display = 'none'; (e.target as HTMLImageElement).style.display = 'none';
}} }}
@@ -708,34 +774,28 @@
</div> </div>
{/if} {/if}
<!-- Grid de Informações -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<!-- Nome --> <!-- Nome -->
{#if dadosExternos.nome} {#if dadosExternos.nome}
<div class="form-control"> <div class="form-control">
<label class="label"> <label class="label pb-2">
<span class="label-text font-semibold">Nome</span> <span class="label-text font-semibold">Nome</span>
</label> </label>
<div class="input input-bordered bg-base-200">{dadosExternos.nome}</div> <div class="input input-bordered bg-base-200/80 text-base-content/90 border-base-300">
{dadosExternos.nome}
</div>
</div> </div>
{/if} {/if}
<!-- Categoria --> <!-- Categoria -->
{#if dadosExternos.categoria} {#if dadosExternos.categoria}
<div class="form-control"> <div class="form-control">
<label class="label"> <label class="label pb-2">
<span class="label-text font-semibold">Categoria</span> <span class="label-text font-semibold">Categoria</span>
</label> </label>
<div class="input input-bordered bg-base-200">{dadosExternos.categoria}</div> <div class="input input-bordered bg-base-200/80 text-base-content/90 border-base-300">
</div> {dadosExternos.categoria}
{/if}
<!-- Descrição -->
{#if dadosExternos.descricao}
<div class="form-control md:col-span-2">
<label class="label">
<span class="label-text font-semibold">Descrição</span>
</label>
<div class="textarea textarea-bordered bg-base-200 min-h-[80px]">
{dadosExternos.descricao}
</div> </div>
</div> </div>
{/if} {/if}
@@ -743,40 +803,59 @@
<!-- Marca --> <!-- Marca -->
{#if dadosExternos.marca} {#if dadosExternos.marca}
<div class="form-control"> <div class="form-control">
<label class="label"> <label class="label pb-2">
<span class="label-text font-semibold">Marca</span> <span class="label-text font-semibold">Marca</span>
</label> </label>
<div class="input input-bordered bg-base-200">{dadosExternos.marca}</div> <div class="input input-bordered bg-base-200/80 text-base-content/90 border-base-300">
{dadosExternos.marca}
</div>
</div> </div>
{/if} {/if}
<!-- Quantidade --> <!-- Quantidade -->
{#if dadosExternos.quantidade} {#if dadosExternos.quantidade}
<div class="form-control"> <div class="form-control">
<label class="label"> <label class="label pb-2">
<span class="label-text font-semibold">Quantidade</span> <span class="label-text font-semibold">Quantidade</span>
</label> </label>
<div class="input input-bordered bg-base-200">{dadosExternos.quantidade}</div> <div class="input input-bordered bg-base-200/80 text-base-content/90 border-base-300">
{dadosExternos.quantidade}
</div>
</div> </div>
{/if} {/if}
<!-- Embalagem --> <!-- Embalagem -->
{#if dadosExternos.embalagem} {#if dadosExternos.embalagem}
<div class="form-control md:col-span-2"> <div class="form-control md:col-span-2">
<label class="label"> <label class="label pb-2">
<span class="label-text font-semibold">Embalagem</span> <span class="label-text font-semibold">Embalagem</span>
</label> </label>
<div class="input input-bordered bg-base-200">{dadosExternos.embalagem}</div> <div class="input input-bordered bg-base-200/80 text-base-content/90 border-base-300">
{dadosExternos.embalagem}
</div>
</div>
{/if}
<!-- Descrição -->
{#if dadosExternos.descricao}
<div class="form-control md:col-span-2">
<label class="label pb-2">
<span class="label-text font-semibold">Descrição</span>
</label>
<div class="textarea textarea-bordered bg-base-200/80 min-h-[100px] text-base-content/90 whitespace-pre-wrap border-base-300">
{dadosExternos.descricao}
</div>
</div> </div>
{/if} {/if}
</div> </div>
</div> </div>
</div>
<!-- Footer --> <!-- Footer -->
<div class="border-base-300 flex flex-shrink-0 justify-end gap-3 border-t px-6 py-4"> <div class="border-base-300 flex flex-shrink-0 justify-end gap-3 border-t bg-base-200/30 px-6 py-4">
<button <button
type="button" type="button"
class="btn btn-ghost" class="btn btn-ghost gap-2"
onclick={descartarDadosExternos} onclick={descartarDadosExternos}
disabled={carregandoImagemExterna} disabled={carregandoImagemExterna}
> >
@@ -785,7 +864,7 @@
</button> </button>
<button <button
type="button" type="button"
class="btn btn-primary" class="btn btn-primary gap-2 shadow-lg"
onclick={usarDadosExternos} onclick={usarDadosExternos}
disabled={carregandoImagemExterna} disabled={carregandoImagemExterna}
> >
@@ -805,26 +884,38 @@
<!-- Modal de Busca em Andamento --> <!-- Modal de Busca em Andamento -->
{#if modalBuscando} {#if modalBuscando}
<div <div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" class="pointer-events-none fixed inset-0 z-50"
style="animation: fadeIn 0.2s ease-out;"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="modal-buscando-title" aria-labelledby="modal-buscando-title"
aria-live="polite"
> >
<!-- Backdrop -->
<div <div
class="bg-base-100 mx-4 w-full max-w-md transform rounded-2xl shadow-2xl transition-all" class="pointer-events-auto absolute inset-0 bg-black/50 backdrop-blur-md transition-opacity duration-300"
aria-hidden="true"
></div>
<!-- Modal Box -->
<div
class="pointer-events-auto absolute left-1/2 top-1/2 z-10 flex max-h-[90vh] w-full max-w-md transform -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-3xl bg-base-100 shadow-2xl transition-all duration-300"
style="animation: slideUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);"
onclick={(e) => e.stopPropagation()} onclick={(e) => e.stopPropagation()}
> >
<!-- Header --> <!-- Header -->
<div class="border-base-300 from-info/10 to-info/5 border-b bg-linear-to-r p-6"> <div
<div class="flex items-center gap-3"> class="border-base-300 from-info/10 via-info/5 to-transparent flex flex-shrink-0 items-center justify-between border-b bg-gradient-to-r px-6 py-5"
<div class="rounded-2xl bg-info/20 p-3"> >
<Loader2 class="h-6 w-6 text-info animate-spin" strokeWidth={2.5} /> <div class="flex items-center gap-4">
<div class="rounded-2xl bg-info/20 p-3 shadow-lg">
<Loader2 class="h-6 w-6 animate-spin text-info" strokeWidth={2.5} />
</div> </div>
<div> <div>
<h3 id="modal-buscando-title" class="text-xl font-bold"> <h3 id="modal-buscando-title" class="text-base-content text-xl font-bold">
Buscando produto... Buscando produto...
</h3> </h3>
<p class="text-base-content/70 text-sm mt-1"> <p class="text-base-content/70 mt-1 text-sm">
Procurando informações do código de barras Procurando informações do código de barras
</p> </p>
</div> </div>
@@ -832,20 +923,24 @@
</div> </div>
<!-- Content --> <!-- Content -->
<div class="p-6"> <div class="flex-1 px-6 py-8">
<div class="flex flex-col items-center justify-center gap-4"> <div class="flex flex-col items-center justify-center gap-6">
<div class="text-center"> <div class="text-center space-y-4">
<p class="text-base-content/80 text-base mb-2"> <div class="inline-flex items-center justify-center rounded-full bg-info/10 p-4">
Código: <span class="font-mono font-semibold">{codigoBarrasBuscado}</span> <Loader2 class="h-8 w-8 animate-spin text-info" strokeWidth={2.5} />
</div>
<div class="space-y-2">
<p class="text-base-content/90 text-base font-medium">
Código: <span class="font-mono font-semibold text-primary">{codigoBarrasBuscado}</span>
</p> </p>
{#if !buscandoExterno} {#if !buscandoExterno}
<div class="flex items-center justify-center gap-2 text-sm text-base-content/60"> <div class="flex items-center justify-center gap-2 text-sm text-base-content/70">
<span class="loading loading-spinner loading-sm"></span> <span class="loading loading-spinner loading-sm text-info"></span>
<span>Buscando no banco de dados...</span> <span>Buscando no banco de dados...</span>
</div> </div>
{:else} {:else}
<div class="flex items-center justify-center gap-2 text-sm text-base-content/60"> <div class="flex items-center justify-center gap-2 text-sm text-base-content/70">
<span class="loading loading-spinner loading-sm"></span> <span class="loading loading-spinner loading-sm text-info"></span>
<span>Buscando em base externa...</span> <span>Buscando em base externa...</span>
</div> </div>
{/if} {/if}
@@ -854,70 +949,83 @@
</div> </div>
</div> </div>
</div> </div>
</div>
{/if} {/if}
<!-- Modal de Produto Não Encontrado --> <!-- Modal de Produto Não Encontrado -->
{#if modalProdutoNaoEncontrado} {#if modalProdutoNaoEncontrado}
<div <div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" class="pointer-events-none fixed inset-0 z-50"
style="animation: fadeIn 0.2s ease-out;"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="modal-nao-encontrado-title" aria-labelledby="modal-nao-encontrado-title"
aria-describedby="modal-nao-encontrado-description"
> >
<!-- Backdrop -->
<div <div
class="bg-base-100 mx-4 w-full max-w-md transform rounded-2xl shadow-2xl transition-all" class="pointer-events-auto absolute inset-0 bg-black/50 backdrop-blur-md transition-opacity duration-300"
onclick={fecharModalProdutoNaoEncontrado}
aria-hidden="true"
></div>
<!-- Modal Box -->
<div
class="pointer-events-auto absolute left-1/2 top-1/2 z-10 flex max-h-[90vh] w-full max-w-md transform -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-3xl bg-base-100 shadow-2xl transition-all duration-300"
style="animation: slideUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);"
onclick={(e) => e.stopPropagation()} onclick={(e) => e.stopPropagation()}
> >
<!-- Header --> <!-- Header -->
<div class="border-base-300 from-warning/10 to-warning/5 border-b bg-linear-to-r p-6"> <div
<div class="flex items-start justify-between"> class="border-base-300 from-warning/10 via-warning/5 to-transparent flex flex-shrink-0 items-center justify-between border-b bg-gradient-to-r px-6 py-5"
<div class="flex items-center gap-3"> >
<div class="rounded-2xl bg-warning/20 p-3"> <div class="flex items-center gap-4">
<div class="rounded-2xl bg-warning/20 p-3 shadow-lg">
<AlertCircle class="h-6 w-6 text-warning" strokeWidth={2.5} /> <AlertCircle class="h-6 w-6 text-warning" strokeWidth={2.5} />
</div> </div>
<div> <div class="flex-1">
<h3 id="modal-nao-encontrado-title" class="text-xl font-bold"> <h3 id="modal-nao-encontrado-title" class="text-base-content text-xl font-bold">
Produto não encontrado Produto não encontrado
</h3> </h3>
<p class="text-base-content/70 text-sm mt-1"> <p id="modal-nao-encontrado-description" class="text-base-content/70 mt-1 text-sm">
O código de barras não foi encontrado O código de barras não foi encontrado
</p> </p>
</div> </div>
</div> </div>
<button <button
type="button" type="button"
class="btn btn-sm btn-circle btn-ghost" class="btn btn-sm btn-circle btn-ghost hover:bg-base-300 transition-colors"
onclick={fecharModalProdutoNaoEncontrado} onclick={fecharModalProdutoNaoEncontrado}
aria-label="Fechar" aria-label="Fechar modal"
> >
<X class="h-5 w-5" /> <X class="h-5 w-5" />
</button> </button>
</div> </div>
</div>
<!-- Content --> <!-- Content -->
<div class="p-6"> <div class="modal-scroll flex-1 overflow-y-auto px-6 py-6">
<div class="flex flex-col gap-4"> <div class="alert alert-info border-info/30 bg-info/5 shadow-md">
<div class="rounded-lg bg-base-200 p-4"> <Info class="h-5 w-5 shrink-0 text-info" strokeWidth={2} />
<div class="flex items-start gap-3"> <div class="flex-1 space-y-2">
<Info class="h-5 w-5 text-info mt-0.5 flex-shrink-0" strokeWidth={2} /> <p class="text-base-content/90 text-sm font-semibold">
<div class="flex-1"> Código de barras: <span class="font-mono font-bold text-primary">{codigoBarrasBuscado}</span>
<p class="text-base-content/90 text-sm font-medium mb-1">
Código de barras: <span class="font-mono font-semibold">{codigoBarrasBuscado}</span>
</p> </p>
<p class="text-base-content/70 text-sm"> <p class="text-base-content/80 text-sm leading-relaxed">
Este produto não foi encontrado no banco de dados local nem em bases externas. Este produto não foi encontrado no banco de dados local nem em bases externas.
Por favor, preencha as informações manualmente. Por favor, preencha as informações manualmente.
</p> </p>
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
<!-- Footer --> <!-- Footer -->
<div class="border-base-300 flex flex-shrink-0 justify-end gap-3 border-t px-6 py-4"> <div class="border-base-300 flex flex-shrink-0 justify-end border-t bg-base-200/30 px-6 py-4">
<button type="button" class="btn btn-primary" onclick={fecharModalProdutoNaoEncontrado}> <button
type="button"
class="btn btn-primary gap-2 shadow-lg"
onclick={fecharModalProdutoNaoEncontrado}
>
<Check class="h-5 w-5" />
Entendi, vou preencher manualmente Entendi, vou preencher manualmente
</button> </button>
</div> </div>
@@ -926,4 +1034,66 @@
{/if} {/if}
</main> </main>
<style>
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translate(-50%, -45%) scale(0.96);
}
to {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
}
/* Scrollbar customizada para modais */
:global(.modal-scroll) {
scrollbar-width: thin;
scrollbar-color: hsl(var(--bc) / 0.3) transparent;
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
}
:global(.modal-scroll::-webkit-scrollbar) {
width: 8px;
}
:global(.modal-scroll::-webkit-scrollbar-track) {
background: transparent;
border-radius: 4px;
}
:global(.modal-scroll::-webkit-scrollbar-thumb) {
background-color: hsl(var(--bc) / 0.3);
border-radius: 4px;
transition: background-color 0.2s ease;
}
:global(.modal-scroll::-webkit-scrollbar-thumb:hover) {
background-color: hsl(var(--bc) / 0.5);
}
/* Melhorias de acessibilidade e responsividade */
@media (max-width: 640px) {
:global([role="dialog"]) > div:last-child {
max-width: calc(100vw - 2rem);
margin: 1rem;
}
}
/* Prevenção de scroll do body quando modal está aberto */
:global(body.modal-open) {
overflow: hidden;
}
</style>

View File

@@ -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 ========== // ========== INTERNAL ACTIONS ==========
export const registrarHistoricoAlteracao = internalMutation({ export const registrarHistoricoAlteracao = internalMutation({