feat: enhance 'Almoxarifado' functionality by integrating barcode scanning for material entry and exit, improving user experience with loading indicators and error handling for better inventory management
This commit is contained in:
@@ -169,7 +169,8 @@
|
||||
{
|
||||
label: 'Listar Materiais',
|
||||
link: '/almoxarifado/materiais',
|
||||
permission: { recurso: 'almoxarifado', acao: 'listar' }
|
||||
permission: { recurso: 'almoxarifado', acao: 'listar' },
|
||||
excludePaths: ['/almoxarifado/materiais/cadastro']
|
||||
},
|
||||
{
|
||||
label: 'Movimentações',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { onMount, onDestroy, tick } from 'svelte';
|
||||
import { Html5Qrcode, type Html5QrcodeResult } from 'html5-qrcode';
|
||||
import { Camera, X, Scan } from 'lucide-svelte';
|
||||
|
||||
@@ -29,7 +29,43 @@
|
||||
};
|
||||
|
||||
async function startScanning() {
|
||||
if (!scannerElement) return;
|
||||
// Aguardar o DOM ser atualizado
|
||||
await tick();
|
||||
|
||||
// Verificar se o elemento existe no DOM
|
||||
const element = document.getElementById(scannerId);
|
||||
if (!element) {
|
||||
// Tentar novamente após um pequeno delay
|
||||
setTimeout(async () => {
|
||||
const retryElement = document.getElementById(scannerId);
|
||||
if (!retryElement) {
|
||||
const errorMsg = 'Elemento do scanner não encontrado no DOM';
|
||||
error = errorMsg;
|
||||
scanning = false;
|
||||
if (onError) {
|
||||
onError(errorMsg);
|
||||
}
|
||||
return;
|
||||
}
|
||||
await startScanningInternal();
|
||||
}, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
await startScanningInternal();
|
||||
}
|
||||
|
||||
async function startScanningInternal() {
|
||||
const element = document.getElementById(scannerId);
|
||||
if (!element) {
|
||||
const errorMsg = 'Elemento do scanner não encontrado';
|
||||
error = errorMsg;
|
||||
scanning = false;
|
||||
if (onError) {
|
||||
onError(errorMsg);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
error = null;
|
||||
@@ -37,26 +73,80 @@
|
||||
|
||||
scanner = new Html5Qrcode(scannerId);
|
||||
|
||||
await scanner.start(
|
||||
{ facingMode: 'environment' },
|
||||
config,
|
||||
(decodedText: string, decodedResult: Html5QrcodeResult) => {
|
||||
handleScannedCode(decodedText);
|
||||
},
|
||||
(errorMessage: string) => {
|
||||
// Ignorar erros de leitura contínua
|
||||
if (errorMessage.includes('No MultiFormat Readers')) {
|
||||
return;
|
||||
// Tentar primeiro com câmera traseira (environment), depois frontal (user)
|
||||
let cameraConfig = { facingMode: 'environment' as const };
|
||||
|
||||
try {
|
||||
await scanner.start(
|
||||
cameraConfig,
|
||||
config,
|
||||
(decodedText: string, decodedResult: Html5QrcodeResult) => {
|
||||
handleScannedCode(decodedText);
|
||||
},
|
||||
(errorMessage: string) => {
|
||||
// Ignorar erros de leitura contínua
|
||||
if (errorMessage.includes('No MultiFormat Readers')) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (cameraError) {
|
||||
// Se falhar com câmera traseira, tentar com frontal (útil para PC)
|
||||
if (cameraConfig.facingMode === 'environment') {
|
||||
console.log('Tentando câmera frontal...');
|
||||
cameraConfig = { facingMode: 'user' };
|
||||
await scanner.start(
|
||||
cameraConfig,
|
||||
config,
|
||||
(decodedText: string, decodedResult: Html5QrcodeResult) => {
|
||||
handleScannedCode(decodedText);
|
||||
},
|
||||
(errorMessage: string) => {
|
||||
if (errorMessage.includes('No MultiFormat Readers')) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
);
|
||||
} else {
|
||||
throw cameraError;
|
||||
}
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Erro ao iniciar scanner';
|
||||
let errorMessage = 'Erro ao iniciar scanner';
|
||||
|
||||
if (err instanceof Error) {
|
||||
errorMessage = err.message;
|
||||
|
||||
// Mensagens de erro mais amigáveis
|
||||
if (errorMessage.includes('Permission denied') || errorMessage.includes('NotAllowedError')) {
|
||||
errorMessage = 'Permissão de câmera negada. Por favor, permita o acesso à câmera nas configurações do navegador.';
|
||||
} else if (errorMessage.includes('NotFoundError') || errorMessage.includes('No camera found')) {
|
||||
errorMessage = 'Nenhuma câmera encontrada. Verifique se há uma câmera conectada ao dispositivo.';
|
||||
} else if (errorMessage.includes('NotReadableError') || errorMessage.includes('TrackStartError')) {
|
||||
errorMessage = 'Câmera está sendo usada por outro aplicativo. Feche outros aplicativos que possam estar usando a câmera.';
|
||||
} else if (errorMessage.includes('OverconstrainedError')) {
|
||||
errorMessage = 'Câmera não suporta as configurações necessárias.';
|
||||
}
|
||||
}
|
||||
|
||||
error = errorMessage;
|
||||
scanning = false;
|
||||
|
||||
// Limpar scanner em caso de erro
|
||||
if (scanner) {
|
||||
try {
|
||||
await scanner.clear();
|
||||
} catch (clearErr) {
|
||||
console.error('Erro ao limpar scanner:', clearErr);
|
||||
}
|
||||
scanner = null;
|
||||
}
|
||||
|
||||
if (onError) {
|
||||
onError(errorMessage);
|
||||
}
|
||||
|
||||
console.error('Erro ao iniciar scanner:', err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,7 +218,12 @@
|
||||
|
||||
$effect(() => {
|
||||
if (enabled && !scanning) {
|
||||
startScanning();
|
||||
// Aguardar um pouco para garantir que o DOM foi atualizado
|
||||
setTimeout(() => {
|
||||
if (enabled && !scanning) {
|
||||
startScanning();
|
||||
}
|
||||
}, 50);
|
||||
} else if (!enabled && scanning) {
|
||||
stopScanning();
|
||||
}
|
||||
@@ -171,12 +266,39 @@
|
||||
{#if error}
|
||||
<div class="alert alert-error mb-4">
|
||||
<span>{error}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-ghost mt-2"
|
||||
onclick={async () => {
|
||||
error = null;
|
||||
scanning = false;
|
||||
// Limpar scanner anterior se existir
|
||||
if (scanner) {
|
||||
try {
|
||||
await scanner.clear();
|
||||
} catch (err) {
|
||||
console.error('Erro ao limpar scanner:', err);
|
||||
}
|
||||
scanner = null;
|
||||
}
|
||||
// Aguardar um pouco antes de tentar novamente
|
||||
await tick();
|
||||
setTimeout(() => {
|
||||
if (enabled && !scanning) {
|
||||
startScanning();
|
||||
}
|
||||
}, 100);
|
||||
}}
|
||||
>
|
||||
Tentar novamente
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if scanning}
|
||||
<div class="relative">
|
||||
<div id={scannerId} bind:this={scannerElement}></div>
|
||||
<!-- Sempre renderizar o elemento quando enabled for true -->
|
||||
<div class="relative">
|
||||
<div id={scannerId} bind:this={scannerElement}></div>
|
||||
{#if scanning}
|
||||
<div class="mt-4 text-center">
|
||||
<p class="text-sm text-base-content/70">
|
||||
Posicione o código de barras dentro da área de leitura
|
||||
@@ -185,13 +307,13 @@
|
||||
Ou use um leitor USB/Bluetooth para escanear
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if !error}
|
||||
<div class="text-center py-8">
|
||||
<Camera class="h-16 w-16 mx-auto mb-4 text-base-content/30" />
|
||||
<p class="text-base-content/70">Iniciando scanner...</p>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if !error}
|
||||
<div class="text-center py-8">
|
||||
<Camera class="h-16 w-16 mx-auto mb-4 text-base-content/30" />
|
||||
<p class="text-base-content/70">Iniciando scanner...</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="card-actions justify-end mt-4">
|
||||
<button type="button" class="btn btn-ghost" onclick={() => { enabled = false; }}>
|
||||
|
||||
@@ -4,14 +4,26 @@
|
||||
import { useConvexClient, useQuery } from 'convex-svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { Package, Plus, Search, Edit, Eye, AlertTriangle, Trash2, Info, X, Filter, Barcode } from 'lucide-svelte';
|
||||
import {
|
||||
Package,
|
||||
Plus,
|
||||
Search,
|
||||
Edit,
|
||||
Eye,
|
||||
AlertTriangle,
|
||||
Trash2,
|
||||
Info,
|
||||
X,
|
||||
Filter,
|
||||
Barcode
|
||||
} from 'lucide-svelte';
|
||||
import BarcodeScanner from '$lib/components/almoxarifado/BarcodeScanner.svelte';
|
||||
|
||||
const client = useConvexClient();
|
||||
|
||||
// Usar useQuery para atualização automática
|
||||
const materiaisQuery = useQuery(api.almoxarifado.listarMateriais, {});
|
||||
|
||||
|
||||
let materiais = $derived.by(() => {
|
||||
try {
|
||||
if (materiaisQuery === undefined || materiaisQuery === null) return [];
|
||||
@@ -36,7 +48,11 @@
|
||||
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 erroExclusao = $state<{
|
||||
titulo: string;
|
||||
mensagem: string;
|
||||
tipo: 'movimentacoes' | 'requisicoes' | 'outro';
|
||||
} | null>(null);
|
||||
let desativando = $state(false);
|
||||
|
||||
const categorias = $derived(
|
||||
@@ -102,7 +118,11 @@
|
||||
await buscarPorCodigoBarras(barcode);
|
||||
if (!materialEncontrado) {
|
||||
// Produto não encontrado, oferecer cadastro
|
||||
if (confirm('Produto não encontrado. Deseja cadastrar um novo produto com este código de barras?')) {
|
||||
if (
|
||||
confirm(
|
||||
'Produto não encontrado. Deseja cadastrar um novo produto com este código de barras?'
|
||||
)
|
||||
) {
|
||||
goto(resolve('/almoxarifado/materiais/cadastro'));
|
||||
}
|
||||
}
|
||||
@@ -158,20 +178,20 @@
|
||||
|
||||
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;
|
||||
@@ -179,24 +199,26 @@
|
||||
} 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.';
|
||||
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.';
|
||||
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
|
||||
@@ -221,18 +243,18 @@
|
||||
|
||||
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;
|
||||
@@ -264,17 +286,26 @@
|
||||
<div class="mb-8">
|
||||
<div class="flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="rounded-2xl bg-gradient-to-br from-primary/20 via-primary/10 to-primary/5 p-4 shadow-lg border border-primary/20">
|
||||
<Package class="h-10 w-10 text-primary" strokeWidth={2.5} />
|
||||
<div
|
||||
class="from-primary/20 via-primary/10 to-primary/5 border-primary/20 rounded-2xl border bg-gradient-to-br p-4 shadow-lg"
|
||||
>
|
||||
<Package class="text-primary h-10 w-10" strokeWidth={2.5} />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h1 class="text-4xl font-bold tracking-tight bg-gradient-to-r from-primary to-primary/70 bg-clip-text text-transparent">
|
||||
<h1
|
||||
class="from-primary to-primary/70 bg-gradient-to-r bg-clip-text text-4xl font-bold tracking-tight text-transparent"
|
||||
>
|
||||
Materiais
|
||||
</h1>
|
||||
<p class="text-base-content/70 text-lg mt-1">Gerencie o cadastro e controle de materiais do almoxarifado</p>
|
||||
<p class="text-base-content/70 mt-1 text-lg">
|
||||
Gerencie o cadastro e controle de materiais do almoxarifado
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-lg shadow-lg hover:shadow-xl transition-all min-w-[200px]" onclick={navCadastro}>
|
||||
<button
|
||||
class="btn btn-primary btn-lg min-w-[200px] shadow-lg transition-all hover:shadow-xl"
|
||||
onclick={navCadastro}
|
||||
>
|
||||
<Plus class="h-5 w-5" />
|
||||
Cadastrar Material
|
||||
</button>
|
||||
@@ -289,14 +320,14 @@
|
||||
{/if}
|
||||
|
||||
<!-- Filtros -->
|
||||
<div class="card bg-base-100 border border-base-300 mb-8 shadow-2xl">
|
||||
<div class="card bg-base-100 border-base-300 mb-8 border shadow-2xl">
|
||||
<div class="card-body p-8">
|
||||
<div class="mb-6 flex items-center justify-between border-b-2 border-primary/20 pb-4">
|
||||
<div class="border-primary/20 mb-6 flex items-center justify-between border-b-2 pb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="rounded-lg bg-primary/10 p-2.5">
|
||||
<Filter class="h-5 w-5 text-primary" strokeWidth={2.5} />
|
||||
<div class="bg-primary/10 rounded-lg p-2.5">
|
||||
<Filter class="text-primary h-5 w-5" strokeWidth={2.5} />
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-base-content">Filtros de Busca</h3>
|
||||
<h3 class="text-base-content text-xl font-bold">Filtros de Busca</h3>
|
||||
</div>
|
||||
<BarcodeScanner
|
||||
enabled={scannerEnabled}
|
||||
@@ -307,25 +338,31 @@
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-4">
|
||||
<div class="form-control">
|
||||
<label class="label pb-2">
|
||||
<span class="label-text font-semibold flex items-center gap-2">
|
||||
<span class="label-text flex items-center gap-2 font-semibold">
|
||||
<Search class="h-4 w-4" />
|
||||
Buscar
|
||||
</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<Search class="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-base-content/40" />
|
||||
<Search class="text-base-content/40 absolute top-1/2 left-3 h-5 w-5 -translate-y-1/2" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Código, nome ou código de barras..."
|
||||
class="input input-bordered w-full pl-10 h-12 focus:input-primary transition-colors {buscandoPorCodigoBarras ? 'input-info' : ''}"
|
||||
class="input input-bordered focus:input-primary h-12 w-full pl-10 transition-colors {buscandoPorCodigoBarras
|
||||
? 'input-info'
|
||||
: ''}"
|
||||
bind:value={filtroBusca}
|
||||
/>
|
||||
{#if buscandoPorCodigoBarras}
|
||||
<span class="loading loading-spinner loading-xs absolute right-3 top-1/2 -translate-y-1/2"></span>
|
||||
<span
|
||||
class="loading loading-spinner loading-xs absolute top-1/2 right-3 -translate-y-1/2"
|
||||
></span>
|
||||
{/if}
|
||||
</div>
|
||||
<label class="label pt-1">
|
||||
<span class="label-text-alt text-base-content/60">Busque por código, nome ou código de barras</span>
|
||||
<span class="label-text-alt text-base-content/60"
|
||||
>Busque por código, nome ou código de barras</span
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -333,7 +370,10 @@
|
||||
<label class="label pb-2">
|
||||
<span class="label-text font-semibold">Categoria</span>
|
||||
</label>
|
||||
<select class="select select-bordered w-full focus:select-primary transition-colors h-12" bind:value={filtroCategoria}>
|
||||
<select
|
||||
class="select select-bordered focus:select-primary h-12 w-full transition-colors"
|
||||
bind:value={filtroCategoria}
|
||||
>
|
||||
<option value="">Todas as categorias</option>
|
||||
{#each categorias as cat}
|
||||
<option value={cat}>{cat}</option>
|
||||
@@ -345,7 +385,10 @@
|
||||
<label class="label pb-2">
|
||||
<span class="label-text font-semibold">Status</span>
|
||||
</label>
|
||||
<select class="select select-bordered w-full focus:select-primary transition-colors h-12" bind:value={filtroAtivo}>
|
||||
<select
|
||||
class="select select-bordered focus:select-primary h-12 w-full transition-colors"
|
||||
bind:value={filtroAtivo}
|
||||
>
|
||||
<option value="">Todos</option>
|
||||
<option value={true}>Ativos</option>
|
||||
<option value={false}>Inativos</option>
|
||||
@@ -356,8 +399,14 @@
|
||||
<label class="label pb-2">
|
||||
<span class="label-text font-semibold">Filtros Adicionais</span>
|
||||
</label>
|
||||
<label class="label cursor-pointer justify-start gap-3 rounded-lg border border-base-300 p-3 hover:bg-base-200 transition-colors">
|
||||
<input type="checkbox" class="checkbox checkbox-primary" bind:checked={filtroEstoqueBaixo} />
|
||||
<label
|
||||
class="label border-base-300 hover:bg-base-200 cursor-pointer justify-start gap-3 rounded-lg border p-3 transition-colors"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-primary"
|
||||
bind:checked={filtroEstoqueBaixo}
|
||||
/>
|
||||
<span class="label-text font-medium">Apenas estoque baixo</span>
|
||||
</label>
|
||||
</div>
|
||||
@@ -366,26 +415,26 @@
|
||||
</div>
|
||||
|
||||
<!-- Tabela -->
|
||||
<div class="card bg-base-100 border border-base-300 shadow-2xl">
|
||||
<div class="card bg-base-100 border-base-300 border shadow-2xl">
|
||||
<div class="card-body p-8">
|
||||
<div class="mb-6 flex items-center gap-3 border-b-2 border-base-300 pb-4">
|
||||
<div class="rounded-lg bg-info/10 p-2.5">
|
||||
<Package class="h-5 w-5 text-info" strokeWidth={2.5} />
|
||||
<div class="border-base-300 mb-6 flex items-center gap-3 border-b-2 pb-4">
|
||||
<div class="bg-info/10 rounded-lg p-2.5">
|
||||
<Package class="text-info h-5 w-5" strokeWidth={2.5} />
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-base-content">Lista de Materiais</h3>
|
||||
<h3 class="text-base-content text-xl font-bold">Lista de Materiais</h3>
|
||||
</div>
|
||||
<div class="overflow-x-auto rounded-lg border border-base-300">
|
||||
<table class="table table-zebra">
|
||||
<div class="border-base-300 overflow-x-auto rounded-lg border">
|
||||
<table class="table-zebra table">
|
||||
<thead>
|
||||
<tr class="bg-base-200">
|
||||
<th class="font-bold text-base-content">Código</th>
|
||||
<th class="font-bold text-base-content">Nome</th>
|
||||
<th class="font-bold text-base-content">Categoria</th>
|
||||
<th class="font-bold text-base-content">Estoque Atual</th>
|
||||
<th class="font-bold text-base-content">Estoque Mínimo</th>
|
||||
<th class="font-bold text-base-content">Unidade</th>
|
||||
<th class="font-bold text-base-content">Status</th>
|
||||
<th class="font-bold text-base-content">Ações</th>
|
||||
<th class="text-base-content font-bold">Código</th>
|
||||
<th class="text-base-content font-bold">Nome</th>
|
||||
<th class="text-base-content font-bold">Categoria</th>
|
||||
<th class="text-base-content font-bold">Estoque Atual</th>
|
||||
<th class="text-base-content font-bold">Estoque Mínimo</th>
|
||||
<th class="text-base-content font-bold">Unidade</th>
|
||||
<th class="text-base-content font-bold">Status</th>
|
||||
<th class="text-base-content font-bold">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -393,22 +442,28 @@
|
||||
<tr>
|
||||
<td colspan="8" class="text-center">
|
||||
<div class="py-16">
|
||||
<Package class="mx-auto mb-4 h-20 w-20 text-base-content/30" />
|
||||
<p class="text-base-content/80 text-xl font-semibold mb-2">Nenhum material encontrado</p>
|
||||
<p class="text-base-content/60 text-base">Tente ajustar os filtros de busca ou cadastre um novo material</p>
|
||||
<Package class="text-base-content/30 mx-auto mb-4 h-20 w-20" />
|
||||
<p class="text-base-content/80 mb-2 text-xl font-semibold">
|
||||
Nenhum material encontrado
|
||||
</p>
|
||||
<p class="text-base-content/60 text-base">
|
||||
Tente ajustar os filtros de busca ou cadastre um novo material
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
{#each filtered as material}
|
||||
<tr class="hover:bg-base-200/50 transition-colors">
|
||||
<tr class="hover:bg-base-200/50 transition-colors" key={material._id}>
|
||||
<td>
|
||||
<div class="font-mono font-bold text-primary">{material.codigo}</div>
|
||||
<div class="text-primary font-mono font-bold">{material.codigo}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="font-medium">{material.nome}</div>
|
||||
{#if material.descricao}
|
||||
<div class="text-sm text-base-content/60 line-clamp-1">{material.descricao}</div>
|
||||
<div class="text-base-content/60 line-clamp-1 text-sm">
|
||||
{material.descricao}
|
||||
</div>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
@@ -416,9 +471,13 @@
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-bold {material.estoqueAtual <= material.estoqueMinimo ? 'text-error' : 'text-success'}">{material.estoqueAtual}</span>
|
||||
<span
|
||||
class="font-bold {material.estoqueAtual <= material.estoqueMinimo
|
||||
? 'text-error'
|
||||
: 'text-success'}">{material.estoqueAtual}</span
|
||||
>
|
||||
{#if material.estoqueAtual <= material.estoqueMinimo}
|
||||
<AlertTriangle class="h-4 w-4 text-warning" />
|
||||
<AlertTriangle class="text-warning h-4 w-4" />
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
@@ -440,13 +499,7 @@
|
||||
<button
|
||||
class="btn btn-sm btn-ghost hover:btn-primary transition-all"
|
||||
title="Visualizar detalhes"
|
||||
onclick={() =>
|
||||
goto(
|
||||
resolve(
|
||||
'/almoxarifado/materiais/' +
|
||||
material._id
|
||||
)
|
||||
)}
|
||||
onclick={() => goto(resolve('/almoxarifado/materiais/' + material._id))}
|
||||
>
|
||||
<Eye class="h-4 w-4" />
|
||||
</button>
|
||||
@@ -454,13 +507,7 @@
|
||||
class="btn btn-sm btn-ghost hover:btn-info transition-all"
|
||||
title="Editar material"
|
||||
onclick={() =>
|
||||
goto(
|
||||
resolve(
|
||||
'/almoxarifado/materiais/' +
|
||||
material._id +
|
||||
'/editar'
|
||||
)
|
||||
)}
|
||||
goto(resolve('/almoxarifado/materiais/' + material._id + '/editar'))}
|
||||
>
|
||||
<Edit class="h-4 w-4" />
|
||||
</button>
|
||||
@@ -481,9 +528,10 @@
|
||||
</div>
|
||||
|
||||
{#if filtered.length > 0}
|
||||
<div class="mt-8 flex items-center justify-between border-t-2 border-base-300 pt-6">
|
||||
<div class="text-base font-semibold text-base-content/80">
|
||||
Mostrando <span class="text-primary font-bold">{filtered.length}</span> de <span class="text-primary font-bold">{materiais.length}</span> materiais
|
||||
<div class="border-base-300 mt-8 flex items-center justify-between border-t-2 pt-6">
|
||||
<div class="text-base-content/80 text-base font-semibold">
|
||||
Mostrando <span class="text-primary font-bold">{filtered.length}</span> de
|
||||
<span class="text-primary font-bold">{materiais.length}</span> materiais
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -492,49 +540,53 @@
|
||||
|
||||
<!-- Modal de Confirmação de Exclusão -->
|
||||
<dialog id="modal-excluir-material" class="modal backdrop-blur-sm">
|
||||
<div class="modal-box max-w-2xl border border-base-300 shadow-2xl">
|
||||
<div class="mb-6 flex items-center gap-4 border-b-2 border-error/20 pb-4">
|
||||
<div class="rounded-2xl bg-error/20 p-3">
|
||||
<AlertTriangle class="h-8 w-8 text-error" strokeWidth={2.5} />
|
||||
<div class="modal-box border-base-300 max-w-2xl border shadow-2xl">
|
||||
<div class="border-error/20 mb-6 flex items-center gap-4 border-b-2 pb-4">
|
||||
<div class="bg-error/20 rounded-2xl p-3">
|
||||
<AlertTriangle class="text-error h-8 w-8" strokeWidth={2.5} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-2xl font-bold text-base-content">Confirmar Exclusão</h3>
|
||||
<h3 class="text-base-content text-2xl font-bold">Confirmar Exclusão</h3>
|
||||
<p class="text-base-content/70 mt-1">Esta ação não pode ser desfeita</p>
|
||||
</div>
|
||||
</div>
|
||||
{#if materialParaExcluir}
|
||||
<div class="space-y-4 mb-6">
|
||||
<div class="mb-6 space-y-4">
|
||||
<div class="alert alert-warning border-warning/30 bg-warning/10">
|
||||
<AlertTriangle class="h-5 w-5 shrink-0 text-warning" />
|
||||
<AlertTriangle class="text-warning h-5 w-5 shrink-0" />
|
||||
<div class="flex-1">
|
||||
<p class="font-semibold text-base-content">Atenção!</p>
|
||||
<p class="text-sm text-base-content/90 mt-1">
|
||||
Esta ação não pode ser desfeita. O material será permanentemente excluído do sistema.
|
||||
<p class="text-base-content font-semibold">Atenção!</p>
|
||||
<p class="text-base-content/90 mt-1 text-sm">
|
||||
Esta ação não pode ser desfeita. O material será permanentemente excluído do
|
||||
sistema.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-base-200 rounded-lg p-5 border border-base-300">
|
||||
<p class="text-sm text-base-content/70 mb-3 font-semibold">Material a ser excluído:</p>
|
||||
<p class="font-bold text-lg text-base-content">{materialParaExcluir.nome}</p>
|
||||
<p class="text-sm text-base-content/60 mt-2">
|
||||
<div class="bg-base-200 border-base-300 rounded-lg border p-5">
|
||||
<p class="text-base-content/70 mb-3 text-sm font-semibold">Material a ser excluído:</p>
|
||||
<p class="text-base-content text-lg font-bold">{materialParaExcluir.nome}</p>
|
||||
<p class="text-base-content/60 mt-2 text-sm">
|
||||
Código: <span class="font-mono font-semibold">{materialParaExcluir.codigo}</span>
|
||||
</p>
|
||||
{#if materialParaExcluir.codigoBarras}
|
||||
<p class="text-sm text-base-content/60 mt-1">
|
||||
Código de Barras: <span class="font-mono font-semibold">{materialParaExcluir.codigoBarras}</span>
|
||||
<p class="text-base-content/60 mt-1 text-sm">
|
||||
Código de Barras: <span class="font-mono font-semibold"
|
||||
>{materialParaExcluir.codigoBarras}</span
|
||||
>
|
||||
</p>
|
||||
{/if}
|
||||
{#if materialParaExcluir.estoqueAtual > 0}
|
||||
<div class="mt-3 alert alert-info py-2 border-info/30 bg-info/10">
|
||||
<div class="alert alert-info border-info/30 bg-info/10 mt-3 py-2">
|
||||
<p class="text-sm font-medium">
|
||||
⚠️ Este material possui <strong>{materialParaExcluir.estoqueAtual}</strong> unidades em estoque.
|
||||
⚠️ Este material possui <strong>{materialParaExcluir.estoqueAtual}</strong> unidades
|
||||
em estoque.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="modal-action gap-3 border-t-2 border-base-300 pt-6">
|
||||
<div class="modal-action border-base-300 gap-3 border-t-2 pt-6">
|
||||
<button
|
||||
class="btn btn-ghost btn-lg min-w-[140px]"
|
||||
onclick={fecharModalExclusao}
|
||||
@@ -565,53 +617,62 @@
|
||||
<!-- Modal de Erro na Exclusão -->
|
||||
{#if erroExclusao}
|
||||
<dialog id="modal-erro-exclusao" class="modal modal-open backdrop-blur-sm">
|
||||
<div class="modal-box max-w-2xl border border-base-300 shadow-2xl">
|
||||
<div class="flex items-center gap-4 mb-6 border-b-2 border-error/20 pb-4">
|
||||
<div class="rounded-2xl bg-error/20 p-3">
|
||||
<AlertTriangle class="h-8 w-8 text-error" strokeWidth={2.5} />
|
||||
<div class="modal-box border-base-300 max-w-2xl border shadow-2xl">
|
||||
<div class="border-error/20 mb-6 flex items-center gap-4 border-b-2 pb-4">
|
||||
<div class="bg-error/20 rounded-2xl p-3">
|
||||
<AlertTriangle class="text-error h-8 w-8" strokeWidth={2.5} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-2xl font-bold text-base-content">{erroExclusao.titulo}</h3>
|
||||
<h3 class="text-base-content text-2xl font-bold">{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="mb-6 space-y-4">
|
||||
<div class="alert alert-error border-error/30 bg-error/10">
|
||||
<AlertTriangle class="h-5 w-5 shrink-0 text-error" />
|
||||
<AlertTriangle class="text-error h-5 w-5 shrink-0" />
|
||||
<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>
|
||||
<p class="text-base-content font-semibold">Motivo do bloqueio</p>
|
||||
<p class="text-base-content/90 mt-1 text-sm">{erroExclusao.mensagem}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if materialParaExcluir}
|
||||
<div class="bg-base-200 rounded-lg p-5 border border-base-300">
|
||||
<p class="text-sm text-base-content/70 mb-3 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-2">
|
||||
<div class="bg-base-200 border-base-300 rounded-lg border p-5">
|
||||
<p class="text-base-content/70 mb-3 text-sm font-semibold">Material:</p>
|
||||
<p class="text-base-content text-lg font-bold">{materialParaExcluir.nome}</p>
|
||||
<p class="text-base-content/60 mt-2 text-sm">
|
||||
Código: <span class="font-mono font-semibold">{materialParaExcluir.codigo}</span>
|
||||
</p>
|
||||
{#if materialParaExcluir.codigoBarras}
|
||||
<p class="text-sm text-base-content/60 mt-1">
|
||||
Código de Barras: <span class="font-mono font-semibold">{materialParaExcluir.codigoBarras}</span>
|
||||
<p class="text-base-content/60 mt-1 text-sm">
|
||||
Código de Barras: <span class="font-mono font-semibold"
|
||||
>{materialParaExcluir.codigoBarras}</span
|
||||
>
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="bg-info/10 border border-info/30 rounded-lg p-5">
|
||||
<div class="bg-info/10 border-info/30 rounded-lg border p-5">
|
||||
<div class="flex gap-3">
|
||||
<Info class="h-5 w-5 text-info shrink-0 mt-0.5" />
|
||||
<Info class="text-info mt-0.5 h-5 w-5 shrink-0" />
|
||||
<div>
|
||||
<p class="font-semibold text-base-content mb-2">Solução recomendada</p>
|
||||
<p class="text-sm text-base-content/80 leading-relaxed">
|
||||
<p class="text-base-content mb-2 font-semibold">Solução recomendada</p>
|
||||
<p class="text-base-content/80 text-sm leading-relaxed">
|
||||
{#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.
|
||||
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.
|
||||
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.
|
||||
Recomendamos verificar as dependências do material antes de tentar excluí-lo
|
||||
novamente.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
@@ -619,7 +680,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-action gap-3 border-t-2 border-base-300 pt-6">
|
||||
<div class="modal-action border-base-300 gap-3 border-t-2 pt-6">
|
||||
<button
|
||||
class="btn btn-ghost btn-lg min-w-[140px]"
|
||||
onclick={fecharModalErro}
|
||||
@@ -650,6 +711,3 @@
|
||||
</dialog>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@
|
||||
import { useConvexClient, useQuery } from 'convex-svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import { ArrowLeftRight, ArrowDown, ArrowUp, Settings, History, Package, FileText, User, Building2, AlertCircle } from 'lucide-svelte';
|
||||
import BarcodeScanner from '$lib/components/almoxarifado/BarcodeScanner.svelte';
|
||||
|
||||
const client = useConvexClient();
|
||||
|
||||
@@ -16,6 +17,8 @@
|
||||
let entradaDocumento = $state('');
|
||||
let entradaObservacoes = $state('');
|
||||
let entradaLoading = $state(false);
|
||||
let entradaScannerEnabled = $state(false);
|
||||
let entradaBuscandoMaterial = $state(false);
|
||||
|
||||
// Estados do formulário de saída
|
||||
let saidaMaterialId = $state<Id<'materiais'> | ''>('');
|
||||
@@ -25,6 +28,8 @@
|
||||
let saidaSetorId = $state<Id<'setores'> | ''>('');
|
||||
let saidaObservacoes = $state('');
|
||||
let saidaLoading = $state(false);
|
||||
let saidaScannerEnabled = $state(false);
|
||||
let saidaBuscandoMaterial = $state(false);
|
||||
|
||||
// Estados do formulário de ajuste
|
||||
let ajusteMaterialId = $state<Id<'materiais'> | ''>('');
|
||||
@@ -68,6 +73,60 @@
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Função para buscar material por código de barras (entrada)
|
||||
async function buscarMaterialPorCodigoBarrasEntrada(codigoBarras: string) {
|
||||
if (!codigoBarras.trim() || codigoBarras.trim().length < 8) {
|
||||
mostrarMensagem('error', 'Código de barras inválido');
|
||||
return;
|
||||
}
|
||||
|
||||
entradaBuscandoMaterial = true;
|
||||
try {
|
||||
const material = await client.query(api.almoxarifado.buscarMaterialPorCodigoBarras, {
|
||||
codigoBarras: codigoBarras.trim()
|
||||
});
|
||||
|
||||
if (material) {
|
||||
entradaMaterialId = material._id;
|
||||
mostrarMensagem('success', `Material encontrado: ${material.nome}`);
|
||||
} else {
|
||||
mostrarMensagem('error', 'Material não encontrado com este código de barras');
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Erro ao buscar material';
|
||||
mostrarMensagem('error', message);
|
||||
} finally {
|
||||
entradaBuscandoMaterial = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Função para buscar material por código de barras (saída)
|
||||
async function buscarMaterialPorCodigoBarrasSaida(codigoBarras: string) {
|
||||
if (!codigoBarras.trim() || codigoBarras.trim().length < 8) {
|
||||
mostrarMensagem('error', 'Código de barras inválido');
|
||||
return;
|
||||
}
|
||||
|
||||
saidaBuscandoMaterial = true;
|
||||
try {
|
||||
const material = await client.query(api.almoxarifado.buscarMaterialPorCodigoBarras, {
|
||||
codigoBarras: codigoBarras.trim()
|
||||
});
|
||||
|
||||
if (material) {
|
||||
saidaMaterialId = material._id;
|
||||
mostrarMensagem('success', `Material encontrado: ${material.nome}`);
|
||||
} else {
|
||||
mostrarMensagem('error', 'Material não encontrado com este código de barras');
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Erro ao buscar material';
|
||||
mostrarMensagem('error', message);
|
||||
} finally {
|
||||
saidaBuscandoMaterial = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function registrarEntrada() {
|
||||
if (!entradaMaterialId || entradaQuantidade <= 0 || !entradaMotivo.trim()) {
|
||||
mostrarMensagem('error', 'Preencha todos os campos obrigatórios');
|
||||
@@ -249,6 +308,15 @@
|
||||
<h2 class="text-2xl font-bold text-base-content">Registrar Entrada de Material</h2>
|
||||
</div>
|
||||
<form onsubmit={(e) => { e.preventDefault(); registrarEntrada(); }}>
|
||||
<!-- Leitor de Código de Barras -->
|
||||
<div class="mb-6 rounded-xl border border-base-300 bg-base-200/50 p-4">
|
||||
<BarcodeScanner
|
||||
enabled={entradaScannerEnabled}
|
||||
onScan={buscarMaterialPorCodigoBarrasEntrada}
|
||||
onError={(error) => mostrarMensagem('error', error)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<!-- Material -->
|
||||
<div class="form-control md:col-span-2">
|
||||
@@ -256,9 +324,12 @@
|
||||
<span class="label-text font-semibold flex items-center gap-2">
|
||||
<Package class="h-4 w-4" />
|
||||
Material <span class="text-error">*</span>
|
||||
{#if entradaBuscandoMaterial}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{/if}
|
||||
</span>
|
||||
</label>
|
||||
<select class="select select-bordered w-full focus:select-primary transition-colors h-12" bind:value={entradaMaterialId} required>
|
||||
<select class="select select-bordered w-full focus:select-primary transition-colors h-12 {entradaBuscandoMaterial ? 'input-info' : ''}" bind:value={entradaMaterialId} required>
|
||||
<option value="">Selecione um material</option>
|
||||
{#if materiaisQuery.data}
|
||||
{#each materiaisQuery.data as material}
|
||||
@@ -268,6 +339,15 @@
|
||||
{/each}
|
||||
{/if}
|
||||
</select>
|
||||
<label class="label pt-1">
|
||||
<span class="label-text-alt text-base-content/60">
|
||||
{#if entradaBuscandoMaterial}
|
||||
<span class="text-info">Buscando material...</span>
|
||||
{:else}
|
||||
Selecione manualmente ou use o leitor de código de barras acima
|
||||
{/if}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Quantidade -->
|
||||
@@ -371,6 +451,15 @@
|
||||
<h2 class="text-2xl font-bold text-base-content">Registrar Saída de Material</h2>
|
||||
</div>
|
||||
<form onsubmit={(e) => { e.preventDefault(); registrarSaida(); }}>
|
||||
<!-- Leitor de Código de Barras -->
|
||||
<div class="mb-6 rounded-xl border border-base-300 bg-base-200/50 p-4">
|
||||
<BarcodeScanner
|
||||
enabled={saidaScannerEnabled}
|
||||
onScan={buscarMaterialPorCodigoBarrasSaida}
|
||||
onError={(error) => mostrarMensagem('error', error)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<!-- Material -->
|
||||
<div class="form-control md:col-span-2">
|
||||
@@ -378,9 +467,12 @@
|
||||
<span class="label-text font-semibold flex items-center gap-2">
|
||||
<Package class="h-4 w-4" />
|
||||
Material <span class="text-error">*</span>
|
||||
{#if saidaBuscandoMaterial}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{/if}
|
||||
</span>
|
||||
</label>
|
||||
<select class="select select-bordered w-full focus:select-primary transition-colors h-12" bind:value={saidaMaterialId} required>
|
||||
<select class="select select-bordered w-full focus:select-primary transition-colors h-12 {saidaBuscandoMaterial ? 'input-info' : ''}" bind:value={saidaMaterialId} required>
|
||||
<option value="">Selecione um material</option>
|
||||
{#if materiaisQuery.data}
|
||||
{#each materiaisQuery.data as material}
|
||||
@@ -390,6 +482,15 @@
|
||||
{/each}
|
||||
{/if}
|
||||
</select>
|
||||
<label class="label pt-1">
|
||||
<span class="label-text-alt text-base-content/60">
|
||||
{#if saidaBuscandoMaterial}
|
||||
<span class="text-info">Buscando material...</span>
|
||||
{:else}
|
||||
Selecione manualmente ou use o leitor de código de barras acima
|
||||
{/if}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Quantidade -->
|
||||
|
||||
@@ -784,7 +784,7 @@
|
||||
<h2 class="text-xl font-bold text-base-content">Estatísticas Gerais</h2>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div class="card bg-gradient-to-br from-primary/10 via-primary/5 to-base-100 border border-primary/20 shadow-xl hover:shadow-2xl transition-all duration-300 hover:scale-105">
|
||||
<div class="card bg-gradient-to-br from-primary/10 via-primary/5 to-base-100 border border-primary/20 shadow-xl hover:shadow-2xl transition-all duration-300 hover:scale-105">
|
||||
<div class="card-body">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
@@ -844,6 +844,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Relatórios Disponíveis -->
|
||||
|
||||
2
packages/backend/convex/_generated/api.d.ts
vendored
2
packages/backend/convex/_generated/api.d.ts
vendored
@@ -10,6 +10,7 @@
|
||||
|
||||
import type * as acoes from "../acoes.js";
|
||||
import type * as actions_buscarInfoProduto from "../actions/buscarInfoProduto.js";
|
||||
import type * as actions_downloadImage from "../actions/downloadImage.js";
|
||||
import type * as actions_email from "../actions/email.js";
|
||||
import type * as actions_linkPreview from "../actions/linkPreview.js";
|
||||
import type * as actions_pushNotifications from "../actions/pushNotifications.js";
|
||||
@@ -109,6 +110,7 @@ import type {
|
||||
declare const fullApi: ApiFromModules<{
|
||||
acoes: typeof acoes;
|
||||
"actions/buscarInfoProduto": typeof actions_buscarInfoProduto;
|
||||
"actions/downloadImage": typeof actions_downloadImage;
|
||||
"actions/email": typeof actions_email;
|
||||
"actions/linkPreview": typeof actions_linkPreview;
|
||||
"actions/pushNotifications": typeof actions_pushNotifications;
|
||||
|
||||
137
packages/backend/convex/actions/downloadImage.ts
Normal file
137
packages/backend/convex/actions/downloadImage.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
'use node';
|
||||
|
||||
import { action } from '../_generated/server';
|
||||
import { v } from 'convex/values';
|
||||
|
||||
/**
|
||||
* Baixa uma imagem de uma URL externa e converte para base64.
|
||||
*
|
||||
* Esta action roda no servidor (Node.js), então não tem restrições de CORS
|
||||
* do navegador. Pode baixar imagens de qualquer domínio.
|
||||
*
|
||||
* @param url - URL da imagem a ser baixada
|
||||
* @returns String base64 da imagem (data URL) ou null se falhar
|
||||
*/
|
||||
export const downloadImageAsBase64 = action({
|
||||
args: {
|
||||
url: v.string()
|
||||
},
|
||||
returns: v.union(v.string(), v.null()),
|
||||
handler: async (ctx, args): Promise<string | null> => {
|
||||
const { url } = args;
|
||||
|
||||
try {
|
||||
// Validar URL
|
||||
let urlObj: URL;
|
||||
try {
|
||||
urlObj = new URL(url);
|
||||
} catch {
|
||||
console.error('URL inválida:', url);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Verificar se é uma URL HTTP/HTTPS
|
||||
if (!['http:', 'https:'].includes(urlObj.protocol)) {
|
||||
console.error('Protocolo não suportado:', urlObj.protocol);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Baixar a imagem (server-side não tem CORS)
|
||||
// Tentar múltiplas estratégias para evitar bloqueios (403) de CDNs
|
||||
type HeadersStrategy = Record<string, string>;
|
||||
|
||||
const estrategias: Array<{ name: string; headers: HeadersStrategy }> = [
|
||||
// Estratégia 1: Headers completos de navegador moderno
|
||||
{
|
||||
name: 'headers-completos',
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
Accept: 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
|
||||
'Accept-Language': 'pt-BR,pt;q=0.9,en-US;q=0.8,en;q=0.7',
|
||||
'Accept-Encoding': 'gzip, deflate, br',
|
||||
Referer: urlObj.origin + '/',
|
||||
'Sec-Fetch-Dest': 'image',
|
||||
'Sec-Fetch-Mode': 'no-cors',
|
||||
'Sec-Fetch-Site': 'cross-site',
|
||||
'Cache-Control': 'no-cache'
|
||||
} as HeadersStrategy
|
||||
},
|
||||
// Estratégia 2: Headers mínimos mas realistas
|
||||
{
|
||||
name: 'headers-minimos',
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||
Accept: 'image/*',
|
||||
Referer: urlObj.origin + '/'
|
||||
} as HeadersStrategy
|
||||
},
|
||||
// Estratégia 3: Apenas User-Agent básico
|
||||
{
|
||||
name: 'user-agent-basico',
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0'
|
||||
} as HeadersStrategy
|
||||
},
|
||||
// Estratégia 4: Sem headers (máximo compatibilidade)
|
||||
{
|
||||
name: 'sem-headers',
|
||||
headers: {} as HeadersStrategy
|
||||
}
|
||||
];
|
||||
|
||||
let ultimoErro: { status?: number; statusText?: string; message?: string } | null = null;
|
||||
|
||||
for (const estrategia of estrategias) {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: estrategia.headers,
|
||||
signal: AbortSignal.timeout(10000) // Timeout de 10 segundos
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Verificar Content-Type
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.startsWith('image/')) {
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
const base64 = buffer.toString('base64');
|
||||
const dataUrl = `data:${contentType};base64,${base64}`;
|
||||
console.log(
|
||||
`✅ Imagem baixada usando estratégia "${estrategia.name}": ${url} (${buffer.length} bytes)`
|
||||
);
|
||||
return dataUrl;
|
||||
} else {
|
||||
console.warn(`Estratégia "${estrategia.name}": Content-Type inválido:`, contentType);
|
||||
}
|
||||
} else {
|
||||
ultimoErro = {
|
||||
status: response.status,
|
||||
statusText: response.statusText
|
||||
};
|
||||
console.warn(
|
||||
`Estratégia "${estrategia.name}" falhou: ${response.status} ${response.statusText}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
ultimoErro = { message: errorMessage };
|
||||
console.warn(`Estratégia "${estrategia.name}" lançou exceção:`, errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
// Se todas as estratégias falharam, logar erro detalhado
|
||||
console.error(
|
||||
'❌ Todas as estratégias falharam ao baixar imagem:',
|
||||
url,
|
||||
'Último erro:',
|
||||
ultimoErro
|
||||
);
|
||||
return null;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error('Erro ao baixar imagem de URL:', url, errorMessage);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user