feat: integrate barcode scanning functionality in 'Almoxarifado' for improved product search and registration, along with image upload support for enhanced inventory management
This commit is contained in:
227
apps/web/src/lib/components/almoxarifado/BarcodeScanner.svelte
Normal file
227
apps/web/src/lib/components/almoxarifado/BarcodeScanner.svelte
Normal file
@@ -0,0 +1,227 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { Html5Qrcode, type Html5QrcodeResult } from 'html5-qrcode';
|
||||
import { Camera, X, Scan } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
onScan: (code: string) => void;
|
||||
onError?: (error: string) => void;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
let { onScan, onError, enabled = $bindable(false) }: Props = $props();
|
||||
|
||||
let scanner: Html5Qrcode | null = $state(null);
|
||||
let scanning = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let scannerElement = $state<HTMLDivElement | null>(null);
|
||||
let inputBuffer = $state('');
|
||||
let inputTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
const scannerId = `barcode-scanner-${Math.random().toString(36).substring(7)}`;
|
||||
|
||||
// Configuração do scanner
|
||||
const config = {
|
||||
fps: 10,
|
||||
qrbox: { width: 250, height: 250 },
|
||||
aspectRatio: 1.0
|
||||
// A biblioteca html5-qrcode suporta automaticamente vários formatos:
|
||||
// EAN-13, EAN-8, UPC-A, UPC-E, Code 128, Code 39, Code 93, QR Code, etc.
|
||||
};
|
||||
|
||||
async function startScanning() {
|
||||
if (!scannerElement) return;
|
||||
|
||||
try {
|
||||
error = null;
|
||||
scanning = true;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Erro ao iniciar scanner';
|
||||
error = errorMessage;
|
||||
scanning = false;
|
||||
if (onError) {
|
||||
onError(errorMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function stopScanning() {
|
||||
if (scanner) {
|
||||
try {
|
||||
await scanner.stop();
|
||||
await scanner.clear();
|
||||
} catch (err) {
|
||||
console.error('Erro ao parar scanner:', err);
|
||||
}
|
||||
scanner = null;
|
||||
}
|
||||
scanning = false;
|
||||
error = null;
|
||||
}
|
||||
|
||||
function handleScannedCode(code: string) {
|
||||
if (code && code.trim()) {
|
||||
stopScanning();
|
||||
enabled = false;
|
||||
onScan(code.trim());
|
||||
}
|
||||
}
|
||||
|
||||
// Suporte para leitores USB/Bluetooth (captura de eventos de teclado)
|
||||
function handleKeyPress(event: KeyboardEvent) {
|
||||
// Ignorar se estiver digitando em um input
|
||||
if (
|
||||
event.target instanceof HTMLInputElement ||
|
||||
event.target instanceof HTMLTextAreaElement ||
|
||||
event.target instanceof HTMLSelectElement
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Leitores de código de barras geralmente enviam caracteres rapidamente
|
||||
if (event.key === 'Enter' && inputBuffer.trim()) {
|
||||
event.preventDefault();
|
||||
handleScannedCode(inputBuffer.trim());
|
||||
inputBuffer = '';
|
||||
if (inputTimeout) {
|
||||
clearTimeout(inputTimeout);
|
||||
inputTimeout = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Acumular caracteres digitados rapidamente
|
||||
if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
||||
inputBuffer += event.key;
|
||||
if (inputTimeout) {
|
||||
clearTimeout(inputTimeout);
|
||||
}
|
||||
inputTimeout = setTimeout(() => {
|
||||
inputBuffer = '';
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleScanner() {
|
||||
if (scanning) {
|
||||
stopScanning();
|
||||
enabled = false;
|
||||
} else {
|
||||
enabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (enabled && !scanning) {
|
||||
startScanning();
|
||||
} else if (!enabled && scanning) {
|
||||
stopScanning();
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
window.addEventListener('keydown', handleKeyPress);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
window.removeEventListener('keydown', handleKeyPress);
|
||||
if (inputTimeout) {
|
||||
clearTimeout(inputTimeout);
|
||||
}
|
||||
stopScanning();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="barcode-scanner">
|
||||
{#if enabled}
|
||||
<div class="card bg-base-100 border border-base-300 shadow-xl">
|
||||
<div class="card-body">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold flex items-center gap-2">
|
||||
<Scan class="h-5 w-5" />
|
||||
Leitor de Código de Barras
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-ghost"
|
||||
onclick={() => {
|
||||
enabled = false;
|
||||
}}
|
||||
aria-label="Fechar scanner"
|
||||
>
|
||||
<X class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="alert alert-error mb-4">
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if scanning}
|
||||
<div class="relative">
|
||||
<div id={scannerId} bind:this={scannerElement}></div>
|
||||
<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
|
||||
</p>
|
||||
<p class="text-xs text-base-content/50 mt-2">
|
||||
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}
|
||||
|
||||
<div class="card-actions justify-end mt-4">
|
||||
<button type="button" class="btn btn-ghost" onclick={() => { enabled = false; }}>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline btn-primary"
|
||||
onclick={toggleScanner}
|
||||
aria-label="Abrir leitor de código de barras"
|
||||
>
|
||||
<Scan class="h-5 w-5" />
|
||||
Ler Código de Barras
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.barcode-scanner {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
[id^='barcode-scanner-'] {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
197
apps/web/src/lib/components/almoxarifado/ImageUpload.svelte
Normal file
197
apps/web/src/lib/components/almoxarifado/ImageUpload.svelte
Normal file
@@ -0,0 +1,197 @@
|
||||
<script lang="ts">
|
||||
import { Image, Upload, X } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
value?: string | null;
|
||||
onChange?: (base64: string | null) => void;
|
||||
maxSizeMB?: number;
|
||||
maxWidth?: number;
|
||||
maxHeight?: number;
|
||||
}
|
||||
|
||||
let {
|
||||
value = $bindable(null),
|
||||
onChange,
|
||||
maxSizeMB = 5,
|
||||
maxWidth = 1200,
|
||||
maxHeight = 1200
|
||||
}: Props = $props();
|
||||
|
||||
let preview = $state<string | null>(value);
|
||||
let error = $state<string | null>(null);
|
||||
let inputElement: HTMLInputElement | null = null;
|
||||
|
||||
function handleFileSelect(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const file = target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
error = null;
|
||||
|
||||
// Validar tamanho
|
||||
if (file.size > maxSizeMB * 1024 * 1024) {
|
||||
error = `Arquivo muito grande. Tamanho máximo: ${maxSizeMB}MB`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Validar tipo
|
||||
if (!file.type.startsWith('image/')) {
|
||||
error = 'Por favor, selecione um arquivo de imagem';
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (e) => {
|
||||
const result = e.target?.result as string;
|
||||
if (result) {
|
||||
// Redimensionar imagem se necessário
|
||||
resizeImage(result, maxWidth, maxHeight)
|
||||
.then((resized) => {
|
||||
preview = resized;
|
||||
value = resized;
|
||||
if (onChange) {
|
||||
onChange(resized);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err instanceof Error ? err.message : 'Erro ao processar imagem';
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
error = 'Erro ao ler arquivo';
|
||||
};
|
||||
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
function resizeImage(
|
||||
dataUrl: string,
|
||||
maxWidth: number,
|
||||
maxHeight: number
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
let width = img.width;
|
||||
let height = img.height;
|
||||
|
||||
// Calcular novas dimensões mantendo proporção
|
||||
if (width > maxWidth || height > maxHeight) {
|
||||
const ratio = Math.min(maxWidth / width, maxHeight / height);
|
||||
width = width * ratio;
|
||||
height = height * ratio;
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
reject(new Error('Não foi possível criar contexto do canvas'));
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
const resizedDataUrl = canvas.toDataURL('image/jpeg', 0.85);
|
||||
resolve(resizedDataUrl);
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
reject(new Error('Erro ao carregar imagem'));
|
||||
};
|
||||
|
||||
img.src = dataUrl;
|
||||
});
|
||||
}
|
||||
|
||||
function removeImage() {
|
||||
preview = null;
|
||||
value = null;
|
||||
if (inputElement) {
|
||||
inputElement.value = '';
|
||||
}
|
||||
if (onChange) {
|
||||
onChange(null);
|
||||
}
|
||||
}
|
||||
|
||||
function triggerFileInput() {
|
||||
inputElement?.click();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
preview = value;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="image-upload">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
bind:this={inputElement}
|
||||
onchange={handleFileSelect}
|
||||
aria-label="Selecionar imagem do produto"
|
||||
/>
|
||||
|
||||
{#if preview}
|
||||
<div class="relative inline-block">
|
||||
<img src={preview} alt="Preview da imagem do produto" class="max-w-full max-h-64 rounded-lg" />
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-circle btn-error absolute top-2 right-2"
|
||||
onclick={removeImage}
|
||||
aria-label="Remover imagem"
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="border-2 border-dashed border-base-300 rounded-lg p-8 text-center cursor-pointer hover:border-primary transition-colors"
|
||||
onclick={triggerFileInput}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
triggerFileInput();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Upload class="h-12 w-12 mx-auto mb-4 text-base-content/40" />
|
||||
<p class="text-base-content/70 font-medium mb-2">Clique para fazer upload da imagem</p>
|
||||
<p class="text-sm text-base-content/50">
|
||||
PNG, JPG ou GIF até {maxSizeMB}MB
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<div class="alert alert-error mt-4">
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if preview}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline btn-primary mt-4"
|
||||
onclick={triggerFileInput}
|
||||
>
|
||||
<Image class="h-4 w-4" />
|
||||
Alterar Imagem
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.image-upload {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user