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',
|
label: 'Listar Materiais',
|
||||||
link: '/almoxarifado/materiais',
|
link: '/almoxarifado/materiais',
|
||||||
permission: { recurso: 'almoxarifado', acao: 'listar' }
|
permission: { recurso: 'almoxarifado', acao: 'listar' },
|
||||||
|
excludePaths: ['/almoxarifado/materiais/cadastro']
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Movimentações',
|
label: 'Movimentações',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy, tick } from 'svelte';
|
||||||
import { Html5Qrcode, type Html5QrcodeResult } from 'html5-qrcode';
|
import { Html5Qrcode, type Html5QrcodeResult } from 'html5-qrcode';
|
||||||
import { Camera, X, Scan } from 'lucide-svelte';
|
import { Camera, X, Scan } from 'lucide-svelte';
|
||||||
|
|
||||||
@@ -29,7 +29,43 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
async function startScanning() {
|
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 {
|
try {
|
||||||
error = null;
|
error = null;
|
||||||
@@ -37,26 +73,80 @@
|
|||||||
|
|
||||||
scanner = new Html5Qrcode(scannerId);
|
scanner = new Html5Qrcode(scannerId);
|
||||||
|
|
||||||
await scanner.start(
|
// Tentar primeiro com câmera traseira (environment), depois frontal (user)
|
||||||
{ facingMode: 'environment' },
|
let cameraConfig = { facingMode: 'environment' as const };
|
||||||
config,
|
|
||||||
(decodedText: string, decodedResult: Html5QrcodeResult) => {
|
try {
|
||||||
handleScannedCode(decodedText);
|
await scanner.start(
|
||||||
},
|
cameraConfig,
|
||||||
(errorMessage: string) => {
|
config,
|
||||||
// Ignorar erros de leitura contínua
|
(decodedText: string, decodedResult: Html5QrcodeResult) => {
|
||||||
if (errorMessage.includes('No MultiFormat Readers')) {
|
handleScannedCode(decodedText);
|
||||||
return;
|
},
|
||||||
|
(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) {
|
} 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;
|
error = errorMessage;
|
||||||
scanning = false;
|
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) {
|
if (onError) {
|
||||||
onError(errorMessage);
|
onError(errorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.error('Erro ao iniciar scanner:', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,7 +218,12 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (enabled && !scanning) {
|
if (enabled && !scanning) {
|
||||||
startScanning();
|
// Aguardar um pouco para garantir que o DOM foi atualizado
|
||||||
|
setTimeout(() => {
|
||||||
|
if (enabled && !scanning) {
|
||||||
|
startScanning();
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
} else if (!enabled && scanning) {
|
} else if (!enabled && scanning) {
|
||||||
stopScanning();
|
stopScanning();
|
||||||
}
|
}
|
||||||
@@ -171,12 +266,39 @@
|
|||||||
{#if error}
|
{#if error}
|
||||||
<div class="alert alert-error mb-4">
|
<div class="alert alert-error mb-4">
|
||||||
<span>{error}</span>
|
<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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if scanning}
|
<!-- Sempre renderizar o elemento quando enabled for true -->
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div id={scannerId} bind:this={scannerElement}></div>
|
<div id={scannerId} bind:this={scannerElement}></div>
|
||||||
|
{#if scanning}
|
||||||
<div class="mt-4 text-center">
|
<div class="mt-4 text-center">
|
||||||
<p class="text-sm text-base-content/70">
|
<p class="text-sm text-base-content/70">
|
||||||
Posicione o código de barras dentro da área de leitura
|
Posicione o código de barras dentro da área de leitura
|
||||||
@@ -185,13 +307,13 @@
|
|||||||
Ou use um leitor USB/Bluetooth para escanear
|
Ou use um leitor USB/Bluetooth para escanear
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{:else if !error}
|
||||||
{:else if !error}
|
<div class="text-center py-8">
|
||||||
<div class="text-center py-8">
|
<Camera class="h-16 w-16 mx-auto mb-4 text-base-content/30" />
|
||||||
<Camera class="h-16 w-16 mx-auto mb-4 text-base-content/30" />
|
<p class="text-base-content/70">Iniciando scanner...</p>
|
||||||
<p class="text-base-content/70">Iniciando scanner...</p>
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
{/if}
|
</div>
|
||||||
|
|
||||||
<div class="card-actions justify-end mt-4">
|
<div class="card-actions justify-end mt-4">
|
||||||
<button type="button" class="btn btn-ghost" onclick={() => { enabled = false; }}>
|
<button type="button" class="btn btn-ghost" onclick={() => { enabled = false; }}>
|
||||||
|
|||||||
@@ -4,7 +4,19 @@
|
|||||||
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, 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';
|
import BarcodeScanner from '$lib/components/almoxarifado/BarcodeScanner.svelte';
|
||||||
|
|
||||||
const client = useConvexClient();
|
const client = useConvexClient();
|
||||||
@@ -36,7 +48,11 @@
|
|||||||
let materialParaExcluir = $state<Doc<'materiais'> | null>(null);
|
let materialParaExcluir = $state<Doc<'materiais'> | null>(null);
|
||||||
let excluindo = $state(false);
|
let excluindo = $state(false);
|
||||||
let notice = $state<{ kind: 'success' | 'error'; text: string } | null>(null);
|
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);
|
let desativando = $state(false);
|
||||||
|
|
||||||
const categorias = $derived(
|
const categorias = $derived(
|
||||||
@@ -102,7 +118,11 @@
|
|||||||
await buscarPorCodigoBarras(barcode);
|
await buscarPorCodigoBarras(barcode);
|
||||||
if (!materialEncontrado) {
|
if (!materialEncontrado) {
|
||||||
// Produto não encontrado, oferecer cadastro
|
// 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'));
|
goto(resolve('/almoxarifado/materiais/cadastro'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -192,11 +212,13 @@
|
|||||||
if (messageLower.includes('movimenta') || messageLower.includes('movimentação')) {
|
if (messageLower.includes('movimenta') || messageLower.includes('movimentação')) {
|
||||||
tipo = 'movimentacoes';
|
tipo = 'movimentacoes';
|
||||||
titulo = 'Material possui movimentações de estoque';
|
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')) {
|
} else if (messageLower.includes('requisi') || messageLower.includes('requisição')) {
|
||||||
tipo = 'requisicoes';
|
tipo = 'requisicoes';
|
||||||
titulo = 'Material possui requisições registradas';
|
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
|
// Exibir modal de erro
|
||||||
@@ -264,17 +286,26 @@
|
|||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<div class="flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
|
<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="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">
|
<div
|
||||||
<Package class="h-10 w-10 text-primary" strokeWidth={2.5} />
|
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>
|
||||||
<div class="flex-1">
|
<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
|
Materiais
|
||||||
</h1>
|
</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>
|
||||||
</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" />
|
<Plus class="h-5 w-5" />
|
||||||
Cadastrar Material
|
Cadastrar Material
|
||||||
</button>
|
</button>
|
||||||
@@ -289,14 +320,14 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Filtros -->
|
<!-- 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="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="flex items-center gap-3">
|
||||||
<div class="rounded-lg bg-primary/10 p-2.5">
|
<div class="bg-primary/10 rounded-lg p-2.5">
|
||||||
<Filter class="h-5 w-5 text-primary" strokeWidth={2.5} />
|
<Filter class="text-primary h-5 w-5" strokeWidth={2.5} />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<BarcodeScanner
|
<BarcodeScanner
|
||||||
enabled={scannerEnabled}
|
enabled={scannerEnabled}
|
||||||
@@ -307,25 +338,31 @@
|
|||||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-4">
|
<div class="grid grid-cols-1 gap-6 md:grid-cols-4">
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label pb-2">
|
<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" />
|
<Search class="h-4 w-4" />
|
||||||
Buscar
|
Buscar
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="relative">
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Código, nome ou código de barras..."
|
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}
|
bind:value={filtroBusca}
|
||||||
/>
|
/>
|
||||||
{#if buscandoPorCodigoBarras}
|
{#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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<label class="label pt-1">
|
<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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -333,7 +370,10 @@
|
|||||||
<label class="label pb-2">
|
<label class="label pb-2">
|
||||||
<span class="label-text font-semibold">Categoria</span>
|
<span class="label-text font-semibold">Categoria</span>
|
||||||
</label>
|
</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>
|
<option value="">Todas as categorias</option>
|
||||||
{#each categorias as cat}
|
{#each categorias as cat}
|
||||||
<option value={cat}>{cat}</option>
|
<option value={cat}>{cat}</option>
|
||||||
@@ -345,7 +385,10 @@
|
|||||||
<label class="label pb-2">
|
<label class="label pb-2">
|
||||||
<span class="label-text font-semibold">Status</span>
|
<span class="label-text font-semibold">Status</span>
|
||||||
</label>
|
</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="">Todos</option>
|
||||||
<option value={true}>Ativos</option>
|
<option value={true}>Ativos</option>
|
||||||
<option value={false}>Inativos</option>
|
<option value={false}>Inativos</option>
|
||||||
@@ -356,8 +399,14 @@
|
|||||||
<label class="label pb-2">
|
<label class="label pb-2">
|
||||||
<span class="label-text font-semibold">Filtros Adicionais</span>
|
<span class="label-text font-semibold">Filtros Adicionais</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="label cursor-pointer justify-start gap-3 rounded-lg border border-base-300 p-3 hover:bg-base-200 transition-colors">
|
<label
|
||||||
<input type="checkbox" class="checkbox checkbox-primary" bind:checked={filtroEstoqueBaixo} />
|
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>
|
<span class="label-text font-medium">Apenas estoque baixo</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -366,26 +415,26 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tabela -->
|
<!-- 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="card-body p-8">
|
||||||
<div class="mb-6 flex items-center gap-3 border-b-2 border-base-300 pb-4">
|
<div class="border-base-300 mb-6 flex items-center gap-3 border-b-2 pb-4">
|
||||||
<div class="rounded-lg bg-info/10 p-2.5">
|
<div class="bg-info/10 rounded-lg p-2.5">
|
||||||
<Package class="h-5 w-5 text-info" strokeWidth={2.5} />
|
<Package class="text-info h-5 w-5" strokeWidth={2.5} />
|
||||||
</div>
|
</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>
|
||||||
<div class="overflow-x-auto rounded-lg border border-base-300">
|
<div class="border-base-300 overflow-x-auto rounded-lg border">
|
||||||
<table class="table table-zebra">
|
<table class="table-zebra table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="bg-base-200">
|
<tr class="bg-base-200">
|
||||||
<th class="font-bold text-base-content">Código</th>
|
<th class="text-base-content font-bold">Código</th>
|
||||||
<th class="font-bold text-base-content">Nome</th>
|
<th class="text-base-content font-bold">Nome</th>
|
||||||
<th class="font-bold text-base-content">Categoria</th>
|
<th class="text-base-content font-bold">Categoria</th>
|
||||||
<th class="font-bold text-base-content">Estoque Atual</th>
|
<th class="text-base-content font-bold">Estoque Atual</th>
|
||||||
<th class="font-bold text-base-content">Estoque Mínimo</th>
|
<th class="text-base-content font-bold">Estoque Mínimo</th>
|
||||||
<th class="font-bold text-base-content">Unidade</th>
|
<th class="text-base-content font-bold">Unidade</th>
|
||||||
<th class="font-bold text-base-content">Status</th>
|
<th class="text-base-content font-bold">Status</th>
|
||||||
<th class="font-bold text-base-content">Ações</th>
|
<th class="text-base-content font-bold">Ações</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -393,22 +442,28 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td colspan="8" class="text-center">
|
<td colspan="8" class="text-center">
|
||||||
<div class="py-16">
|
<div class="py-16">
|
||||||
<Package class="mx-auto mb-4 h-20 w-20 text-base-content/30" />
|
<Package class="text-base-content/30 mx-auto mb-4 h-20 w-20" />
|
||||||
<p class="text-base-content/80 text-xl font-semibold mb-2">Nenhum material encontrado</p>
|
<p class="text-base-content/80 mb-2 text-xl font-semibold">
|
||||||
<p class="text-base-content/60 text-base">Tente ajustar os filtros de busca ou cadastre um novo material</p>
|
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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{:else}
|
{:else}
|
||||||
{#each filtered as material}
|
{#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>
|
<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>
|
||||||
<td>
|
<td>
|
||||||
<div class="font-medium">{material.nome}</div>
|
<div class="font-medium">{material.nome}</div>
|
||||||
{#if material.descricao}
|
{#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}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -416,9 +471,13 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="flex items-center gap-2">
|
<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}
|
{#if material.estoqueAtual <= material.estoqueMinimo}
|
||||||
<AlertTriangle class="h-4 w-4 text-warning" />
|
<AlertTriangle class="text-warning h-4 w-4" />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -440,13 +499,7 @@
|
|||||||
<button
|
<button
|
||||||
class="btn btn-sm btn-ghost hover:btn-primary transition-all"
|
class="btn btn-sm btn-ghost hover:btn-primary transition-all"
|
||||||
title="Visualizar detalhes"
|
title="Visualizar detalhes"
|
||||||
onclick={() =>
|
onclick={() => goto(resolve('/almoxarifado/materiais/' + material._id))}
|
||||||
goto(
|
|
||||||
resolve(
|
|
||||||
'/almoxarifado/materiais/' +
|
|
||||||
material._id
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<Eye class="h-4 w-4" />
|
<Eye class="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -454,13 +507,7 @@
|
|||||||
class="btn btn-sm btn-ghost hover:btn-info transition-all"
|
class="btn btn-sm btn-ghost hover:btn-info transition-all"
|
||||||
title="Editar material"
|
title="Editar material"
|
||||||
onclick={() =>
|
onclick={() =>
|
||||||
goto(
|
goto(resolve('/almoxarifado/materiais/' + material._id + '/editar'))}
|
||||||
resolve(
|
|
||||||
'/almoxarifado/materiais/' +
|
|
||||||
material._id +
|
|
||||||
'/editar'
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<Edit class="h-4 w-4" />
|
<Edit class="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -481,9 +528,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if filtered.length > 0}
|
{#if filtered.length > 0}
|
||||||
<div class="mt-8 flex items-center justify-between border-t-2 border-base-300 pt-6">
|
<div class="border-base-300 mt-8 flex items-center justify-between border-t-2 pt-6">
|
||||||
<div class="text-base font-semibold text-base-content/80">
|
<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
|
Mostrando <span class="text-primary font-bold">{filtered.length}</span> de
|
||||||
|
<span class="text-primary font-bold">{materiais.length}</span> materiais
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -492,49 +540,53 @@
|
|||||||
|
|
||||||
<!-- Modal de Confirmação de Exclusão -->
|
<!-- Modal de Confirmação de Exclusão -->
|
||||||
<dialog id="modal-excluir-material" class="modal backdrop-blur-sm">
|
<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="modal-box border-base-300 max-w-2xl border shadow-2xl">
|
||||||
<div class="mb-6 flex items-center gap-4 border-b-2 border-error/20 pb-4">
|
<div class="border-error/20 mb-6 flex items-center gap-4 border-b-2 pb-4">
|
||||||
<div class="rounded-2xl bg-error/20 p-3">
|
<div class="bg-error/20 rounded-2xl p-3">
|
||||||
<AlertTriangle class="h-8 w-8 text-error" strokeWidth={2.5} />
|
<AlertTriangle class="text-error h-8 w-8" strokeWidth={2.5} />
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<p class="text-base-content/70 mt-1">Esta ação não pode ser desfeita</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if materialParaExcluir}
|
{#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">
|
<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">
|
<div class="flex-1">
|
||||||
<p class="font-semibold text-base-content">Atenção!</p>
|
<p class="text-base-content font-semibold">Atenção!</p>
|
||||||
<p class="text-sm text-base-content/90 mt-1">
|
<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.
|
Esta ação não pode ser desfeita. O material será permanentemente excluído do
|
||||||
|
sistema.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-base-200 rounded-lg p-5 border border-base-300">
|
<div class="bg-base-200 border-base-300 rounded-lg border p-5">
|
||||||
<p class="text-sm text-base-content/70 mb-3 font-semibold">Material a ser excluído:</p>
|
<p class="text-base-content/70 mb-3 text-sm font-semibold">Material a ser excluído:</p>
|
||||||
<p class="font-bold text-lg text-base-content">{materialParaExcluir.nome}</p>
|
<p class="text-base-content text-lg font-bold">{materialParaExcluir.nome}</p>
|
||||||
<p class="text-sm text-base-content/60 mt-2">
|
<p class="text-base-content/60 mt-2 text-sm">
|
||||||
Código: <span class="font-mono font-semibold">{materialParaExcluir.codigo}</span>
|
Código: <span class="font-mono font-semibold">{materialParaExcluir.codigo}</span>
|
||||||
</p>
|
</p>
|
||||||
{#if materialParaExcluir.codigoBarras}
|
{#if materialParaExcluir.codigoBarras}
|
||||||
<p class="text-sm text-base-content/60 mt-1">
|
<p class="text-base-content/60 mt-1 text-sm">
|
||||||
Código de Barras: <span class="font-mono font-semibold">{materialParaExcluir.codigoBarras}</span>
|
Código de Barras: <span class="font-mono font-semibold"
|
||||||
|
>{materialParaExcluir.codigoBarras}</span
|
||||||
|
>
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#if materialParaExcluir.estoqueAtual > 0}
|
{#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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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
|
<button
|
||||||
class="btn btn-ghost btn-lg min-w-[140px]"
|
class="btn btn-ghost btn-lg min-w-[140px]"
|
||||||
onclick={fecharModalExclusao}
|
onclick={fecharModalExclusao}
|
||||||
@@ -565,53 +617,62 @@
|
|||||||
<!-- Modal de Erro na Exclusão -->
|
<!-- Modal de Erro na Exclusão -->
|
||||||
{#if erroExclusao}
|
{#if erroExclusao}
|
||||||
<dialog id="modal-erro-exclusao" class="modal modal-open backdrop-blur-sm">
|
<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="modal-box border-base-300 max-w-2xl border shadow-2xl">
|
||||||
<div class="flex items-center gap-4 mb-6 border-b-2 border-error/20 pb-4">
|
<div class="border-error/20 mb-6 flex items-center gap-4 border-b-2 pb-4">
|
||||||
<div class="rounded-2xl bg-error/20 p-3">
|
<div class="bg-error/20 rounded-2xl p-3">
|
||||||
<AlertTriangle class="h-8 w-8 text-error" strokeWidth={2.5} />
|
<AlertTriangle class="text-error h-8 w-8" strokeWidth={2.5} />
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<p class="text-base-content/70 mt-1">Não foi possível excluir o material</p>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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">
|
<div class="flex-1">
|
||||||
<p class="font-semibold text-base-content">Motivo do bloqueio</p>
|
<p class="text-base-content font-semibold">Motivo do bloqueio</p>
|
||||||
<p class="text-sm text-base-content/90 mt-1">{erroExclusao.mensagem}</p>
|
<p class="text-base-content/90 mt-1 text-sm">{erroExclusao.mensagem}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if materialParaExcluir}
|
{#if materialParaExcluir}
|
||||||
<div class="bg-base-200 rounded-lg p-5 border border-base-300">
|
<div class="bg-base-200 border-base-300 rounded-lg border p-5">
|
||||||
<p class="text-sm text-base-content/70 mb-3 font-semibold">Material:</p>
|
<p class="text-base-content/70 mb-3 text-sm font-semibold">Material:</p>
|
||||||
<p class="font-bold text-lg text-base-content">{materialParaExcluir.nome}</p>
|
<p class="text-base-content text-lg font-bold">{materialParaExcluir.nome}</p>
|
||||||
<p class="text-sm text-base-content/60 mt-2">
|
<p class="text-base-content/60 mt-2 text-sm">
|
||||||
Código: <span class="font-mono font-semibold">{materialParaExcluir.codigo}</span>
|
Código: <span class="font-mono font-semibold">{materialParaExcluir.codigo}</span>
|
||||||
</p>
|
</p>
|
||||||
{#if materialParaExcluir.codigoBarras}
|
{#if materialParaExcluir.codigoBarras}
|
||||||
<p class="text-sm text-base-content/60 mt-1">
|
<p class="text-base-content/60 mt-1 text-sm">
|
||||||
Código de Barras: <span class="font-mono font-semibold">{materialParaExcluir.codigoBarras}</span>
|
Código de Barras: <span class="font-mono font-semibold"
|
||||||
|
>{materialParaExcluir.codigoBarras}</span
|
||||||
|
>
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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">
|
<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>
|
<div>
|
||||||
<p class="font-semibold text-base-content mb-2">Solução recomendada</p>
|
<p class="text-base-content mb-2 font-semibold">Solução recomendada</p>
|
||||||
<p class="text-sm text-base-content/80 leading-relaxed">
|
<p class="text-base-content/80 text-sm leading-relaxed">
|
||||||
{#if erroExclusao.tipo === 'movimentacoes'}
|
{#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'}
|
{: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}
|
{: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}
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -619,7 +680,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</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
|
<button
|
||||||
class="btn btn-ghost btn-lg min-w-[140px]"
|
class="btn btn-ghost btn-lg min-w-[140px]"
|
||||||
onclick={fecharModalErro}
|
onclick={fecharModalErro}
|
||||||
@@ -650,6 +711,3 @@
|
|||||||
</dialog>
|
</dialog>
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@
|
|||||||
import { useConvexClient, useQuery } from 'convex-svelte';
|
import { useConvexClient, useQuery } from 'convex-svelte';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import { ArrowLeftRight, ArrowDown, ArrowUp, Settings, History, Package, FileText, User, Building2, AlertCircle } from 'lucide-svelte';
|
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();
|
const client = useConvexClient();
|
||||||
|
|
||||||
@@ -16,6 +17,8 @@
|
|||||||
let entradaDocumento = $state('');
|
let entradaDocumento = $state('');
|
||||||
let entradaObservacoes = $state('');
|
let entradaObservacoes = $state('');
|
||||||
let entradaLoading = $state(false);
|
let entradaLoading = $state(false);
|
||||||
|
let entradaScannerEnabled = $state(false);
|
||||||
|
let entradaBuscandoMaterial = $state(false);
|
||||||
|
|
||||||
// Estados do formulário de saída
|
// Estados do formulário de saída
|
||||||
let saidaMaterialId = $state<Id<'materiais'> | ''>('');
|
let saidaMaterialId = $state<Id<'materiais'> | ''>('');
|
||||||
@@ -25,6 +28,8 @@
|
|||||||
let saidaSetorId = $state<Id<'setores'> | ''>('');
|
let saidaSetorId = $state<Id<'setores'> | ''>('');
|
||||||
let saidaObservacoes = $state('');
|
let saidaObservacoes = $state('');
|
||||||
let saidaLoading = $state(false);
|
let saidaLoading = $state(false);
|
||||||
|
let saidaScannerEnabled = $state(false);
|
||||||
|
let saidaBuscandoMaterial = $state(false);
|
||||||
|
|
||||||
// Estados do formulário de ajuste
|
// Estados do formulário de ajuste
|
||||||
let ajusteMaterialId = $state<Id<'materiais'> | ''>('');
|
let ajusteMaterialId = $state<Id<'materiais'> | ''>('');
|
||||||
@@ -68,6 +73,60 @@
|
|||||||
}, 5000);
|
}, 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() {
|
async function registrarEntrada() {
|
||||||
if (!entradaMaterialId || entradaQuantidade <= 0 || !entradaMotivo.trim()) {
|
if (!entradaMaterialId || entradaQuantidade <= 0 || !entradaMotivo.trim()) {
|
||||||
mostrarMensagem('error', 'Preencha todos os campos obrigatórios');
|
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>
|
<h2 class="text-2xl font-bold text-base-content">Registrar Entrada de Material</h2>
|
||||||
</div>
|
</div>
|
||||||
<form onsubmit={(e) => { e.preventDefault(); registrarEntrada(); }}>
|
<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">
|
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||||
<!-- Material -->
|
<!-- Material -->
|
||||||
<div class="form-control md:col-span-2">
|
<div class="form-control md:col-span-2">
|
||||||
@@ -256,9 +324,12 @@
|
|||||||
<span class="label-text font-semibold flex items-center gap-2">
|
<span class="label-text font-semibold flex items-center gap-2">
|
||||||
<Package class="h-4 w-4" />
|
<Package class="h-4 w-4" />
|
||||||
Material <span class="text-error">*</span>
|
Material <span class="text-error">*</span>
|
||||||
|
{#if entradaBuscandoMaterial}
|
||||||
|
<span class="loading loading-spinner loading-xs"></span>
|
||||||
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</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>
|
<option value="">Selecione um material</option>
|
||||||
{#if materiaisQuery.data}
|
{#if materiaisQuery.data}
|
||||||
{#each materiaisQuery.data as material}
|
{#each materiaisQuery.data as material}
|
||||||
@@ -268,6 +339,15 @@
|
|||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
</select>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Quantidade -->
|
<!-- Quantidade -->
|
||||||
@@ -371,6 +451,15 @@
|
|||||||
<h2 class="text-2xl font-bold text-base-content">Registrar Saída de Material</h2>
|
<h2 class="text-2xl font-bold text-base-content">Registrar Saída de Material</h2>
|
||||||
</div>
|
</div>
|
||||||
<form onsubmit={(e) => { e.preventDefault(); registrarSaida(); }}>
|
<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">
|
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||||
<!-- Material -->
|
<!-- Material -->
|
||||||
<div class="form-control md:col-span-2">
|
<div class="form-control md:col-span-2">
|
||||||
@@ -378,9 +467,12 @@
|
|||||||
<span class="label-text font-semibold flex items-center gap-2">
|
<span class="label-text font-semibold flex items-center gap-2">
|
||||||
<Package class="h-4 w-4" />
|
<Package class="h-4 w-4" />
|
||||||
Material <span class="text-error">*</span>
|
Material <span class="text-error">*</span>
|
||||||
|
{#if saidaBuscandoMaterial}
|
||||||
|
<span class="loading loading-spinner loading-xs"></span>
|
||||||
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</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>
|
<option value="">Selecione um material</option>
|
||||||
{#if materiaisQuery.data}
|
{#if materiaisQuery.data}
|
||||||
{#each materiaisQuery.data as material}
|
{#each materiaisQuery.data as material}
|
||||||
@@ -390,6 +482,15 @@
|
|||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
</select>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Quantidade -->
|
<!-- Quantidade -->
|
||||||
|
|||||||
@@ -784,7 +784,7 @@
|
|||||||
<h2 class="text-xl font-bold text-base-content">Estatísticas Gerais</h2>
|
<h2 class="text-xl font-bold text-base-content">Estatísticas Gerais</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
|
<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="card-body">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
@@ -844,6 +844,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Relatórios Disponíveis -->
|
<!-- 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 acoes from "../acoes.js";
|
||||||
import type * as actions_buscarInfoProduto from "../actions/buscarInfoProduto.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_email from "../actions/email.js";
|
||||||
import type * as actions_linkPreview from "../actions/linkPreview.js";
|
import type * as actions_linkPreview from "../actions/linkPreview.js";
|
||||||
import type * as actions_pushNotifications from "../actions/pushNotifications.js";
|
import type * as actions_pushNotifications from "../actions/pushNotifications.js";
|
||||||
@@ -109,6 +110,7 @@ import type {
|
|||||||
declare const fullApi: ApiFromModules<{
|
declare const fullApi: ApiFromModules<{
|
||||||
acoes: typeof acoes;
|
acoes: typeof acoes;
|
||||||
"actions/buscarInfoProduto": typeof actions_buscarInfoProduto;
|
"actions/buscarInfoProduto": typeof actions_buscarInfoProduto;
|
||||||
|
"actions/downloadImage": typeof actions_downloadImage;
|
||||||
"actions/email": typeof actions_email;
|
"actions/email": typeof actions_email;
|
||||||
"actions/linkPreview": typeof actions_linkPreview;
|
"actions/linkPreview": typeof actions_linkPreview;
|
||||||
"actions/pushNotifications": typeof actions_pushNotifications;
|
"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