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
try { // Se após 500ms ainda for URL, carregar agora
const imagemBase64Carregada = await carregarImagemDeUrl(dadosExternos.imagemUrl); await new Promise((resolve) => setTimeout(resolve, 500));
if (imagemBase64Carregada) {
imagemParaSalvar = imagemBase64Carregada; // Verificar novamente se foi carregada em background
console.log('Imagem carregada e convertida para base64 com sucesso'); if (dadosExternos.imagemUrl && dadosExternos.imagemUrl.startsWith('data:')) {
} else { imagemParaSalvar = dadosExternos.imagemUrl;
console.warn('Não foi possível carregar a imagem da URL:', 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 // 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,60 +700,72 @@
<!-- 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">
<ExternalLink class="h-6 w-6 text-primary" strokeWidth={2.5} /> <div class="rounded-2xl bg-primary/20 p-3 shadow-lg">
</div> <ExternalLink class="h-6 w-6 text-primary" strokeWidth={2.5} />
<div> </div>
<h3 id="modal-dados-externos-title" class="text-xl font-bold"> <div class="flex-1">
Produto encontrado externamente <h3 id="modal-dados-externos-title" class="text-base-content text-2xl font-bold">
</h3> Produto encontrado externamente
<p class="text-base-content/70 text-sm mt-1"> </h3>
{#if dadosExternos?.fonte} <p id="modal-dados-externos-description" class="text-base-content/70 mt-1 text-sm">
Dados encontrados em: <span class="font-semibold text-primary">{dadosExternos.fonte}</span> {#if dadosExternos?.fonte}
{:else} Dados encontrados em: <span class="font-semibold text-primary">{dadosExternos.fonte}</span>
Dados encontrados na base externa. Deseja usar essas informações? {:else}
{/if} Dados encontrados na base externa. Deseja usar essas informações?
</p> {/if}
</div> </p>
</div> </div>
<button
type="button"
class="btn btn-sm btn-circle btn-ghost"
onclick={descartarDadosExternos}
aria-label="Fechar"
>
<X class="h-5 w-5" />
</button>
</div> </div>
<button
type="button"
class="btn btn-sm btn-circle btn-ghost hover:bg-base-300 transition-colors"
onclick={descartarDadosExternos}
aria-label="Fechar modal"
>
<X class="h-5 w-5" />
</button>
</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,84 +774,97 @@
</div> </div>
{/if} {/if}
<!-- Nome --> <!-- Grid de Informações -->
{#if dadosExternos.nome} <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control"> <!-- Nome -->
<label class="label"> {#if dadosExternos.nome}
<span class="label-text font-semibold">Nome</span> <div class="form-control">
</label> <label class="label pb-2">
<div class="input input-bordered bg-base-200">{dadosExternos.nome}</div> <span class="label-text font-semibold">Nome</span>
</div> </label>
{/if} <div class="input input-bordered bg-base-200/80 text-base-content/90 border-base-300">
{dadosExternos.nome}
<!-- Categoria --> </div>
{#if dadosExternos.categoria}
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Categoria</span>
</label>
<div class="input input-bordered bg-base-200">{dadosExternos.categoria}</div>
</div>
{/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> {/if}
{/if}
<!-- Marca --> <!-- Categoria -->
{#if dadosExternos.marca} {#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">Marca</span> <span class="label-text font-semibold">Categoria</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">
</div> {dadosExternos.categoria}
{/if} </div>
</div>
{/if}
<!-- Quantidade --> <!-- Marca -->
{#if dadosExternos.quantidade} {#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">Quantidade</span> <span class="label-text font-semibold">Marca</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">
</div> {dadosExternos.marca}
{/if} </div>
</div>
{/if}
<!-- Embalagem --> <!-- Quantidade -->
{#if dadosExternos.embalagem} {#if dadosExternos.quantidade}
<div class="form-control md:col-span-2"> <div class="form-control">
<label class="label"> <label class="label pb-2">
<span class="label-text font-semibold">Embalagem</span> <span class="label-text font-semibold">Quantidade</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">
</div> {dadosExternos.quantidade}
{/if} </div>
</div>
{/if}
<!-- Embalagem -->
{#if dadosExternos.embalagem}
<div class="form-control md:col-span-2">
<label class="label pb-2">
<span class="label-text font-semibold">Embalagem</span>
</label>
<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>
{/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}
> >
<X class="h-5 w-5" /> <X class="h-5 w-5" />
Descartar Descartar
</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,23 +923,28 @@
</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} />
</p> </div>
{#if !buscandoExterno} <div class="space-y-2">
<div class="flex items-center justify-center gap-2 text-sm text-base-content/60"> <p class="text-base-content/90 text-base font-medium">
<span class="loading loading-spinner loading-sm"></span> Código: <span class="font-mono font-semibold text-primary">{codigoBarrasBuscado}</span>
<span>Buscando no banco de dados...</span> </p>
</div> {#if !buscandoExterno}
{:else} <div class="flex items-center justify-center gap-2 text-sm text-base-content/70">
<div class="flex items-center justify-center gap-2 text-sm text-base-content/60"> <span class="loading loading-spinner loading-sm text-info"></span>
<span class="loading loading-spinner loading-sm"></span> <span>Buscando no banco de dados...</span>
<span>Buscando em base externa...</span> </div>
</div> {:else}
{/if} <div class="flex items-center justify-center gap-2 text-sm text-base-content/70">
<span class="loading loading-spinner loading-sm text-info"></span>
<span>Buscando em base externa...</span>
</div>
{/if}
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -859,65 +955,77 @@
<!-- 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">
<AlertCircle class="h-6 w-6 text-warning" strokeWidth={2.5} /> <div class="rounded-2xl bg-warning/20 p-3 shadow-lg">
</div> <AlertCircle class="h-6 w-6 text-warning" strokeWidth={2.5} />
<div> </div>
<h3 id="modal-nao-encontrado-title" class="text-xl font-bold"> <div class="flex-1">
Produto não encontrado <h3 id="modal-nao-encontrado-title" class="text-base-content text-xl font-bold">
</h3> Produto não encontrado
<p class="text-base-content/70 text-sm mt-1"> </h3>
O código de barras não foi encontrado <p id="modal-nao-encontrado-description" class="text-base-content/70 mt-1 text-sm">
</p> O código de barras não foi encontrado
</div> </p>
</div> </div>
<button
type="button"
class="btn btn-sm btn-circle btn-ghost"
onclick={fecharModalProdutoNaoEncontrado}
aria-label="Fechar"
>
<X class="h-5 w-5" />
</button>
</div> </div>
<button
type="button"
class="btn btn-sm btn-circle btn-ghost hover:bg-base-300 transition-colors"
onclick={fecharModalProdutoNaoEncontrado}
aria-label="Fechar modal"
>
<X class="h-5 w-5" />
</button>
</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"> </p>
Código de barras: <span class="font-mono font-semibold">{codigoBarrasBuscado}</span> <p class="text-base-content/80 text-sm leading-relaxed">
</p> Este produto não foi encontrado no banco de dados local nem em bases externas.
<p class="text-base-content/70 text-sm"> Por favor, preencha as informações manualmente.
Este produto não foi encontrado no banco de dados local nem em bases externas. </p>
Por favor, preencha as informações manualmente.
</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({