feat: implement barcode search configuration in 'Almoxarifado', integrating multiple external APIs for enhanced product information retrieval and improving user experience with new modals for data handling
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Image, Upload, X } from 'lucide-svelte';
|
||||
import { Image as ImageIcon, Upload, X, Camera } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
value?: string | null;
|
||||
@@ -20,6 +20,10 @@
|
||||
let preview = $state<string | null>(value);
|
||||
let error = $state<string | null>(null);
|
||||
let inputElement: HTMLInputElement | null = null;
|
||||
let showCamera = $state(false);
|
||||
let videoElement: HTMLVideoElement | null = null;
|
||||
let stream: MediaStream | null = null;
|
||||
let capturing = $state(false);
|
||||
|
||||
function handleFileSelect(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
@@ -73,7 +77,7 @@
|
||||
maxHeight: number
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
const img = new window.Image();
|
||||
img.onload = () => {
|
||||
let width = img.width;
|
||||
let height = img.height;
|
||||
@@ -123,8 +127,170 @@
|
||||
inputElement?.click();
|
||||
}
|
||||
|
||||
async function openCamera() {
|
||||
// Se já estiver inicializando ou já tiver stream, não fazer nada
|
||||
if (stream) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Primeiro, abrir o modal
|
||||
showCamera = true;
|
||||
capturing = false;
|
||||
error = null;
|
||||
|
||||
// Aguardar o próximo tick para garantir que o DOM foi atualizado
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
|
||||
try {
|
||||
// Verificar se a API está disponível
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
error = 'Câmera não disponível neste dispositivo ou navegador não suporta acesso à câmera';
|
||||
showCamera = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Aguardar o elemento de vídeo estar disponível no DOM
|
||||
let attempts = 0;
|
||||
while (!videoElement && attempts < 30) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
attempts++;
|
||||
}
|
||||
|
||||
if (!videoElement) {
|
||||
throw new Error('Elemento de vídeo não encontrado no DOM');
|
||||
}
|
||||
|
||||
// Solicitar acesso à câmera
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: {
|
||||
facingMode: 'environment', // Câmera traseira por padrão
|
||||
width: { ideal: 1280 },
|
||||
height: { ideal: 720 }
|
||||
}
|
||||
});
|
||||
|
||||
// Atribuir stream ao vídeo
|
||||
videoElement.srcObject = stream;
|
||||
|
||||
// Aguardar o vídeo estar pronto e começar a reproduzir
|
||||
await videoElement.play();
|
||||
|
||||
// Aguardar metadata estar carregado
|
||||
if (videoElement.readyState < 2) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
if (!videoElement) {
|
||||
reject(new Error('Elemento de vídeo não encontrado'));
|
||||
return;
|
||||
}
|
||||
|
||||
const onLoadedMetadata = () => {
|
||||
videoElement?.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||
videoElement?.removeEventListener('error', onError);
|
||||
resolve();
|
||||
};
|
||||
|
||||
const onError = () => {
|
||||
videoElement?.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||
videoElement?.removeEventListener('error', onError);
|
||||
reject(new Error('Erro ao carregar vídeo'));
|
||||
};
|
||||
|
||||
videoElement.addEventListener('loadedmetadata', onLoadedMetadata, { once: true });
|
||||
videoElement.addEventListener('error', onError, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
capturing = true;
|
||||
} catch (err) {
|
||||
console.error('Erro ao acessar câmera:', err);
|
||||
let errorMessage = 'Erro ao acessar câmera';
|
||||
|
||||
if (err instanceof Error) {
|
||||
if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') {
|
||||
errorMessage = 'Permissão de acesso à câmera negada. Por favor, permita o acesso à câmera nas configurações do navegador.';
|
||||
} else if (err.name === 'NotFoundError' || err.name === 'DevicesNotFoundError') {
|
||||
errorMessage = 'Nenhuma câmera encontrada no dispositivo.';
|
||||
} else if (err.name === 'NotReadableError' || err.name === 'TrackStartError') {
|
||||
errorMessage = 'Câmera está sendo usada por outro aplicativo.';
|
||||
} else {
|
||||
errorMessage = err.message || errorMessage;
|
||||
}
|
||||
}
|
||||
|
||||
error = errorMessage;
|
||||
showCamera = false;
|
||||
capturing = false;
|
||||
stopCamera();
|
||||
}
|
||||
}
|
||||
|
||||
function stopCamera() {
|
||||
if (stream) {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
stream = null;
|
||||
}
|
||||
if (videoElement) {
|
||||
videoElement.srcObject = null;
|
||||
}
|
||||
capturing = false;
|
||||
}
|
||||
|
||||
function closeCamera() {
|
||||
stopCamera();
|
||||
showCamera = false;
|
||||
error = null;
|
||||
}
|
||||
|
||||
async function capturePhoto() {
|
||||
if (!videoElement) return;
|
||||
|
||||
try {
|
||||
// Criar canvas para capturar o frame
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = videoElement.videoWidth;
|
||||
canvas.height = videoElement.videoHeight;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
error = 'Não foi possível criar contexto do canvas';
|
||||
return;
|
||||
}
|
||||
|
||||
// Desenhar o frame atual do vídeo no canvas
|
||||
ctx.drawImage(videoElement, 0, 0);
|
||||
|
||||
// Converter para base64
|
||||
const dataUrl = canvas.toDataURL('image/jpeg', 0.85);
|
||||
|
||||
// Redimensionar e processar
|
||||
const resized = await resizeImage(dataUrl, maxWidth, maxHeight);
|
||||
preview = resized;
|
||||
value = resized;
|
||||
if (onChange) {
|
||||
onChange(resized);
|
||||
}
|
||||
|
||||
// Fechar câmera
|
||||
closeCamera();
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Erro ao capturar foto';
|
||||
console.error('Erro ao capturar foto:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Sincronizar preview com value sempre que value mudar
|
||||
$effect(() => {
|
||||
preview = value;
|
||||
if (value !== preview) {
|
||||
preview = value;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Limpar stream quando o componente for desmontado
|
||||
$effect(() => {
|
||||
return () => {
|
||||
stopCamera();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -151,23 +317,34 @@
|
||||
</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 class="flex flex-col gap-4">
|
||||
<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>
|
||||
<div class="divider text-sm">ou</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline btn-primary w-full"
|
||||
onclick={openCamera}
|
||||
>
|
||||
<Camera class="h-5 w-5" />
|
||||
Capturar da Câmera
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -178,17 +355,89 @@
|
||||
{/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>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline btn-primary flex-1"
|
||||
onclick={triggerFileInput}
|
||||
>
|
||||
<ImageIcon class="h-4 w-4" />
|
||||
Alterar Imagem
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline btn-primary flex-1"
|
||||
onclick={openCamera}
|
||||
>
|
||||
<Camera class="h-4 w-4" />
|
||||
Capturar Foto
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Modal da Câmera -->
|
||||
{#if showCamera}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/80" onclick={closeCamera}>
|
||||
<div
|
||||
class="bg-base-100 rounded-lg shadow-2xl p-6 max-w-2xl w-full mx-4"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-xl font-bold">Capturar Foto</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-circle btn-ghost"
|
||||
onclick={closeCamera}
|
||||
aria-label="Fechar câmera"
|
||||
>
|
||||
<X class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="relative bg-black rounded-lg overflow-hidden mb-4" style="aspect-ratio: 4/3; min-height: 300px;">
|
||||
{#if showCamera}
|
||||
<video
|
||||
bind:this={videoElement}
|
||||
autoplay
|
||||
playsinline
|
||||
muted
|
||||
class="w-full h-full object-cover"
|
||||
style="transform: scaleX(-1); opacity: {capturing ? '1' : '0'}; transition: opacity 0.3s;"
|
||||
></video>
|
||||
{/if}
|
||||
{#if !capturing}
|
||||
<div class="flex items-center justify-center h-full absolute inset-0 z-10">
|
||||
<div class="text-center">
|
||||
<span class="loading loading-spinner loading-lg text-primary mb-2"></span>
|
||||
<p class="text-base-content/70 text-sm">Iniciando câmera...</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost"
|
||||
onclick={closeCamera}
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
onclick={capturePhoto}
|
||||
disabled={!capturing}
|
||||
>
|
||||
<Camera class="h-5 w-5" />
|
||||
Capturar Foto
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.image-upload {
|
||||
width: 100%;
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
AlertTriangle,
|
||||
ArrowLeftRight,
|
||||
BarChart3,
|
||||
CheckCircle2
|
||||
CheckCircle2,
|
||||
Settings
|
||||
} from 'lucide-svelte';
|
||||
import BarChart3D from '$lib/components/ti/charts/BarChart3D.svelte';
|
||||
|
||||
@@ -291,7 +292,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Ações Rápidas -->
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||
<button
|
||||
class="card bg-base-100 border border-base-300 shadow-xl hover:shadow-2xl hover:border-primary/50 transition-all duration-300 hover:scale-[1.02] group"
|
||||
onclick={() => goto('/almoxarifado/materiais/cadastro')}
|
||||
@@ -336,6 +337,21 @@
|
||||
<p class="text-base-content/70 text-sm">Visualizar relatórios e estatísticas</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="card bg-base-100 border border-base-300 shadow-xl hover:shadow-2xl hover:border-warning/50 transition-all duration-300 hover:scale-[1.02] group"
|
||||
onclick={() => goto('/ti/configuracoes-almoxarifado')}
|
||||
>
|
||||
<div class="card-body">
|
||||
<div class="flex items-center gap-4 mb-2">
|
||||
<div class="rounded-xl bg-warning/20 p-3 group-hover:bg-warning/30 transition-colors">
|
||||
<Settings class="h-7 w-7 text-warning" strokeWidth={2.5} />
|
||||
</div>
|
||||
<h3 class="card-title text-lg mb-0">Configurações</h3>
|
||||
</div>
|
||||
<p class="text-base-content/70 text-sm">Configurar sistema de almoxarifado</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useConvexClient } from 'convex-svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { Package, Save, ArrowLeft } from 'lucide-svelte';
|
||||
import { Package, Save, ArrowLeft, Check, X, ExternalLink, Loader2, AlertCircle, Info } from 'lucide-svelte';
|
||||
import BarcodeScanner from '$lib/components/almoxarifado/BarcodeScanner.svelte';
|
||||
import ImageUpload from '$lib/components/almoxarifado/ImageUpload.svelte';
|
||||
|
||||
@@ -24,9 +24,24 @@
|
||||
let scannerEnabled = $state(false);
|
||||
let loading = $state(false);
|
||||
let buscandoProduto = $state(false);
|
||||
let buscandoExterno = $state(false);
|
||||
let notice = $state<{ kind: 'success' | 'error'; text: string } | null>(null);
|
||||
let buscaTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let ultimoCodigoBuscado = $state('');
|
||||
let dadosExternos = $state<{
|
||||
nome?: string;
|
||||
descricao?: string;
|
||||
categoria?: string;
|
||||
imagemUrl?: string;
|
||||
marca?: string;
|
||||
quantidade?: string;
|
||||
embalagem?: string;
|
||||
fonte?: string;
|
||||
} | null>(null);
|
||||
let modalDadosExternos = $state(false);
|
||||
let modalBuscando = $state(false);
|
||||
let modalProdutoNaoEncontrado = $state(false);
|
||||
let codigoBarrasBuscado = $state('');
|
||||
let carregandoImagemExterna = $state(false);
|
||||
|
||||
const unidadesMedida = ['UN', 'CX', 'KG', 'L', 'M', 'M²', 'M³', 'PC', 'DZ'];
|
||||
const categoriasComuns = [
|
||||
@@ -46,6 +61,124 @@
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
async function carregarImagemDeUrl(url: string): Promise<string | null> {
|
||||
try {
|
||||
// Tentar carregar a imagem
|
||||
const response = await fetch(url, {
|
||||
mode: 'cors',
|
||||
headers: {
|
||||
'Accept': 'image/*'
|
||||
},
|
||||
// Adicionar referrer policy para evitar problemas de CORS
|
||||
referrerPolicy: 'no-referrer'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('Erro ao carregar imagem: HTTP', response.status, url);
|
||||
return null;
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
|
||||
// Verificar se é realmente uma imagem
|
||||
if (!blob.type.startsWith('image/')) {
|
||||
console.warn('URL não retornou uma imagem:', url);
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const result = reader.result as string;
|
||||
if (result) {
|
||||
resolve(result);
|
||||
} else {
|
||||
reject(new Error('Falha ao converter imagem para base64'));
|
||||
}
|
||||
};
|
||||
reader.onerror = () => {
|
||||
reject(new Error('Erro ao ler arquivo de imagem'));
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Erro ao carregar imagem de URL:', url, err);
|
||||
// Se falhar por CORS, tentar usar um proxy ou retornar null
|
||||
// Por enquanto, apenas logar o erro
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function usarDadosExternos() {
|
||||
if (!dadosExternos) return;
|
||||
|
||||
carregandoImagemExterna = true;
|
||||
|
||||
try {
|
||||
// Preencher campos com dados externos
|
||||
if (dadosExternos.nome) {
|
||||
nome = dadosExternos.nome;
|
||||
}
|
||||
if (dadosExternos.descricao) {
|
||||
descricao = dadosExternos.descricao;
|
||||
}
|
||||
if (dadosExternos.categoria) {
|
||||
categoria = dadosExternos.categoria;
|
||||
}
|
||||
if (dadosExternos.marca) {
|
||||
fornecedor = dadosExternos.marca;
|
||||
}
|
||||
|
||||
// Carregar imagem se disponível (aguardar conversão para garantir que seja salva e exibida)
|
||||
if (dadosExternos.imagemUrl) {
|
||||
let imagemParaSalvar: string | null = null;
|
||||
|
||||
if (dadosExternos.imagemUrl.startsWith('data:')) {
|
||||
// Já está em base64
|
||||
imagemParaSalvar = dadosExternos.imagemUrl;
|
||||
} else {
|
||||
// Aguardar carregar a imagem da URL antes de continuar
|
||||
try {
|
||||
const imagemBase64Carregada = await carregarImagemDeUrl(dadosExternos.imagemUrl);
|
||||
if (imagemBase64Carregada) {
|
||||
imagemParaSalvar = imagemBase64Carregada;
|
||||
console.log('Imagem carregada e convertida para base64 com sucesso');
|
||||
} else {
|
||||
console.warn('Não foi possível carregar a imagem da URL:', dadosExternos.imagemUrl);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Erro ao carregar imagem:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Atribuir a imagem após o carregamento completo
|
||||
if (imagemParaSalvar) {
|
||||
imagemBase64 = imagemParaSalvar;
|
||||
}
|
||||
}
|
||||
|
||||
// Fechar modal
|
||||
modalDadosExternos = false;
|
||||
const dadosTemp = dadosExternos;
|
||||
dadosExternos = null;
|
||||
|
||||
mostrarMensagem('success', 'Dados externos aplicados! Revise e complete as informações restantes.');
|
||||
} finally {
|
||||
carregandoImagemExterna = false;
|
||||
}
|
||||
}
|
||||
|
||||
function descartarDadosExternos() {
|
||||
modalDadosExternos = false;
|
||||
dadosExternos = null;
|
||||
mostrarMensagem('success', 'Código de barras lido. Complete as informações do produto.');
|
||||
}
|
||||
|
||||
function fecharModalProdutoNaoEncontrado() {
|
||||
modalProdutoNaoEncontrado = false;
|
||||
mostrarMensagem('success', 'Código de barras lido. Complete as informações do produto manualmente.');
|
||||
}
|
||||
|
||||
async function buscarProdutoPorCodigoBarras(barcode: string, mostrarMensagemSucesso = true) {
|
||||
if (!barcode.trim() || barcode.trim().length < 8) {
|
||||
return;
|
||||
@@ -58,6 +191,8 @@
|
||||
|
||||
buscandoProduto = true;
|
||||
ultimoCodigoBuscado = barcode.trim();
|
||||
codigoBarrasBuscado = barcode.trim();
|
||||
modalBuscando = true;
|
||||
|
||||
try {
|
||||
// Buscar produto existente pelo código de barras
|
||||
@@ -66,6 +201,9 @@
|
||||
});
|
||||
|
||||
if (materialExistente) {
|
||||
// Fechar modal de busca
|
||||
modalBuscando = false;
|
||||
|
||||
// Preencher campos automaticamente
|
||||
codigo = materialExistente.codigo;
|
||||
nome = materialExistente.nome;
|
||||
@@ -83,14 +221,56 @@
|
||||
mostrarMensagem('success', 'Produto encontrado! Campos preenchidos automaticamente.');
|
||||
}
|
||||
} else {
|
||||
// Produto não encontrado
|
||||
if (mostrarMensagemSucesso) {
|
||||
mostrarMensagem('success', 'Código de barras lido. Complete as informações do produto.');
|
||||
// Produto não encontrado localmente, buscar externamente
|
||||
buscandoExterno = true;
|
||||
try {
|
||||
const infoExterna = await client.action(api['actions/buscarInfoProduto'].buscarInfoProdutoPorCodigoBarras, {
|
||||
codigoBarras: barcode.trim()
|
||||
});
|
||||
|
||||
if (infoExterna && (infoExterna.nome || infoExterna.descricao)) {
|
||||
// Fechar modal de busca
|
||||
modalBuscando = false;
|
||||
|
||||
// Dados encontrados externamente
|
||||
dadosExternos = { ...infoExterna };
|
||||
|
||||
// Tentar carregar imagem se disponível (em background, não bloquear)
|
||||
if (infoExterna.imagemUrl && !infoExterna.imagemUrl.startsWith('data:')) {
|
||||
carregarImagemDeUrl(infoExterna.imagemUrl)
|
||||
.then((imagemBase64Carregada) => {
|
||||
if (imagemBase64Carregada) {
|
||||
dadosExternos = { ...dadosExternos, imagemUrl: imagemBase64Carregada };
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Erro ao carregar imagem:', err);
|
||||
// Manter URL original se falhar
|
||||
});
|
||||
}
|
||||
|
||||
// Mostrar modal para o usuário confirmar o uso dos dados
|
||||
modalDadosExternos = true;
|
||||
} else {
|
||||
// Fechar modal de busca e mostrar modal de produto não encontrado
|
||||
modalBuscando = false;
|
||||
modalProdutoNaoEncontrado = true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Erro ao buscar dados externos:', err);
|
||||
// Fechar modal de busca e mostrar modal de produto não encontrado
|
||||
modalBuscando = false;
|
||||
modalProdutoNaoEncontrado = true;
|
||||
} finally {
|
||||
buscandoExterno = false;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Erro ao buscar produto';
|
||||
|
||||
// Fechar modal de busca
|
||||
modalBuscando = false;
|
||||
|
||||
// Verificar se é erro de função não encontrada
|
||||
if (errorMessage.includes('Could not find public function')) {
|
||||
const message = 'Servidor Convex precisa ser reiniciado. A função de busca por código de barras não foi encontrada.';
|
||||
@@ -114,36 +294,33 @@
|
||||
await buscarProdutoPorCodigoBarras(barcode, true);
|
||||
}
|
||||
|
||||
// Busca automática quando código de barras é digitado manualmente
|
||||
$effect(() => {
|
||||
const codigo = codigoBarras.trim();
|
||||
|
||||
// Limpar timeout anterior
|
||||
if (buscaTimeout) {
|
||||
clearTimeout(buscaTimeout);
|
||||
}
|
||||
// Verificar se deve executar busca automaticamente quando código tem 13 caracteres
|
||||
function verificarEExecutarBusca(codigo: string) {
|
||||
const codigoLimpo = codigo.trim();
|
||||
|
||||
// Se o código foi limpo, resetar último código buscado
|
||||
if (!codigo) {
|
||||
if (!codigoLimpo) {
|
||||
ultimoCodigoBuscado = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Aguardar 800ms após parar de digitar antes de buscar
|
||||
// Isso evita muitas buscas enquanto o usuário está digitando
|
||||
buscaTimeout = setTimeout(() => {
|
||||
// Só buscar se o código tiver pelo menos 8 caracteres (tamanho mínimo de código de barras)
|
||||
if (codigo.length >= 8) {
|
||||
buscarProdutoPorCodigoBarras(codigo, false);
|
||||
}
|
||||
}, 800);
|
||||
// Se o código tem exatamente 13 caracteres (EAN-13), buscar automaticamente
|
||||
if (codigoLimpo.length === 13) {
|
||||
buscarProdutoPorCodigoBarras(codigoLimpo, false);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (buscaTimeout) {
|
||||
clearTimeout(buscaTimeout);
|
||||
// Handler para tecla Enter no campo de código de barras
|
||||
function handleBarcodeKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
const codigoLimpo = codigoBarras.trim();
|
||||
// Buscar se tiver pelo menos 8 caracteres
|
||||
if (codigoLimpo.length >= 8) {
|
||||
buscarProdutoPorCodigoBarras(codigoLimpo, false);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
// Validação
|
||||
@@ -275,20 +452,24 @@
|
||||
<div class="form-control md:col-span-1">
|
||||
<label class="label">
|
||||
<span class="label-text">Código de Barras</span>
|
||||
{#if buscandoProduto}
|
||||
{#if buscandoProduto || buscandoExterno}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{/if}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
class="input input-bordered {buscandoProduto ? 'input-info' : ''}"
|
||||
class="input input-bordered {(buscandoProduto || buscandoExterno) ? 'input-info' : ''}"
|
||||
placeholder="EAN-13, UPC, etc."
|
||||
bind:value={codigoBarras}
|
||||
onkeydown={handleBarcodeKeydown}
|
||||
oninput={() => verificarEExecutarBusca(codigoBarras)}
|
||||
/>
|
||||
<label class="label">
|
||||
<span class="label-text-alt">
|
||||
{#if buscandoProduto}
|
||||
Buscando produto...
|
||||
Buscando produto no banco de dados...
|
||||
{:else if buscandoExterno}
|
||||
Buscando produto em base externa...
|
||||
{:else}
|
||||
Digite ou use o leitor acima para escanear
|
||||
{/if}
|
||||
@@ -461,6 +642,288 @@
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal de Dados Externos Encontrados -->
|
||||
{#if modalDadosExternos && dadosExternos}
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-dados-externos-title"
|
||||
>
|
||||
<div
|
||||
class="bg-base-100 mx-4 w-full max-w-2xl transform rounded-2xl shadow-2xl transition-all"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="border-base-300 from-primary/10 to-secondary/10 border-b bg-linear-to-r p-6">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="rounded-2xl bg-primary/20 p-3">
|
||||
<ExternalLink class="h-6 w-6 text-primary" strokeWidth={2.5} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 id="modal-dados-externos-title" class="text-xl font-bold">
|
||||
Produto encontrado externamente
|
||||
</h3>
|
||||
<p class="text-base-content/70 text-sm mt-1">
|
||||
{#if dadosExternos?.fonte}
|
||||
Dados encontrados em: <span class="font-semibold text-primary">{dadosExternos.fonte}</span>
|
||||
{:else}
|
||||
Dados encontrados na base externa. Deseja usar essas informações?
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-circle btn-ghost"
|
||||
onclick={descartarDadosExternos}
|
||||
aria-label="Fechar"
|
||||
>
|
||||
<X class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="max-h-[60vh] overflow-y-auto p-6">
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<!-- Imagem do Produto -->
|
||||
{#if dadosExternos.imagemUrl}
|
||||
<div class="md:col-span-2">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Imagem do Produto</span>
|
||||
</label>
|
||||
<div class="flex justify-center">
|
||||
<img
|
||||
src={dadosExternos.imagemUrl}
|
||||
alt="Imagem do produto"
|
||||
class="max-h-64 rounded-lg border border-base-300"
|
||||
onerror={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Nome -->
|
||||
{#if dadosExternos.nome}
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Nome</span>
|
||||
</label>
|
||||
<div class="input input-bordered bg-base-200">{dadosExternos.nome}</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Categoria -->
|
||||
{#if dadosExternos.categoria}
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Categoria</span>
|
||||
</label>
|
||||
<div class="input input-bordered bg-base-200">{dadosExternos.categoria}</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Descrição -->
|
||||
{#if dadosExternos.descricao}
|
||||
<div class="form-control md:col-span-2">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Descrição</span>
|
||||
</label>
|
||||
<div class="textarea textarea-bordered bg-base-200 min-h-[80px]">
|
||||
{dadosExternos.descricao}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Marca -->
|
||||
{#if dadosExternos.marca}
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Marca</span>
|
||||
</label>
|
||||
<div class="input input-bordered bg-base-200">{dadosExternos.marca}</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Quantidade -->
|
||||
{#if dadosExternos.quantidade}
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Quantidade</span>
|
||||
</label>
|
||||
<div class="input input-bordered bg-base-200">{dadosExternos.quantidade}</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Embalagem -->
|
||||
{#if dadosExternos.embalagem}
|
||||
<div class="form-control md:col-span-2">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Embalagem</span>
|
||||
</label>
|
||||
<div class="input input-bordered bg-base-200">{dadosExternos.embalagem}</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="border-base-300 flex flex-shrink-0 justify-end gap-3 border-t px-6 py-4">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost"
|
||||
onclick={descartarDadosExternos}
|
||||
disabled={carregandoImagemExterna}
|
||||
>
|
||||
<X class="h-5 w-5" />
|
||||
Descartar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
onclick={usarDadosExternos}
|
||||
disabled={carregandoImagemExterna}
|
||||
>
|
||||
{#if carregandoImagemExterna}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
Carregando imagem...
|
||||
{:else}
|
||||
<Check class="h-5 w-5" />
|
||||
Usar Estes Dados
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Modal de Busca em Andamento -->
|
||||
{#if modalBuscando}
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-buscando-title"
|
||||
>
|
||||
<div
|
||||
class="bg-base-100 mx-4 w-full max-w-md transform rounded-2xl shadow-2xl transition-all"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="border-base-300 from-info/10 to-info/5 border-b bg-linear-to-r p-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="rounded-2xl bg-info/20 p-3">
|
||||
<Loader2 class="h-6 w-6 text-info animate-spin" strokeWidth={2.5} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 id="modal-buscando-title" class="text-xl font-bold">
|
||||
Buscando produto...
|
||||
</h3>
|
||||
<p class="text-base-content/70 text-sm mt-1">
|
||||
Procurando informações do código de barras
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-6">
|
||||
<div class="flex flex-col items-center justify-center gap-4">
|
||||
<div class="text-center">
|
||||
<p class="text-base-content/80 text-base mb-2">
|
||||
Código: <span class="font-mono font-semibold">{codigoBarrasBuscado}</span>
|
||||
</p>
|
||||
{#if !buscandoExterno}
|
||||
<div class="flex items-center justify-center gap-2 text-sm text-base-content/60">
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
<span>Buscando no banco de dados...</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center justify-center gap-2 text-sm text-base-content/60">
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
<span>Buscando em base externa...</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Modal de Produto Não Encontrado -->
|
||||
{#if modalProdutoNaoEncontrado}
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-nao-encontrado-title"
|
||||
>
|
||||
<div
|
||||
class="bg-base-100 mx-4 w-full max-w-md transform rounded-2xl shadow-2xl transition-all"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="border-base-300 from-warning/10 to-warning/5 border-b bg-linear-to-r p-6">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="rounded-2xl bg-warning/20 p-3">
|
||||
<AlertCircle class="h-6 w-6 text-warning" strokeWidth={2.5} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 id="modal-nao-encontrado-title" class="text-xl font-bold">
|
||||
Produto não encontrado
|
||||
</h3>
|
||||
<p class="text-base-content/70 text-sm mt-1">
|
||||
O código de barras não foi encontrado
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-circle btn-ghost"
|
||||
onclick={fecharModalProdutoNaoEncontrado}
|
||||
aria-label="Fechar"
|
||||
>
|
||||
<X class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-6">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="rounded-lg bg-base-200 p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<Info class="h-5 w-5 text-info mt-0.5 flex-shrink-0" strokeWidth={2} />
|
||||
<div class="flex-1">
|
||||
<p class="text-base-content/90 text-sm font-medium mb-1">
|
||||
Código de barras: <span class="font-mono font-semibold">{codigoBarrasBuscado}</span>
|
||||
</p>
|
||||
<p class="text-base-content/70 text-sm">
|
||||
Este produto não foi encontrado no banco de dados local nem em bases externas.
|
||||
Por favor, preencha as informações manualmente.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="border-base-300 flex flex-shrink-0 justify-end gap-3 border-t px-6 py-4">
|
||||
<button type="button" class="btn btn-primary" onclick={fecharModalProdutoNaoEncontrado}>
|
||||
Entendi, vou preencher manualmente
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import type { FunctionReference } from 'convex/server';
|
||||
import { useConvexClient, useQuery } from 'convex-svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import { Settings, Save, AlertTriangle, Info } from 'lucide-svelte';
|
||||
import { Settings, Save, AlertTriangle, Info, Barcode, Key, CheckCircle, XCircle } from 'lucide-svelte';
|
||||
|
||||
const client = useConvexClient();
|
||||
const configAtual = useQuery(api.configuracaoAlmoxarifado.obterConfiguracao, {});
|
||||
const currentUser = useQuery(api.auth.getCurrentUser as FunctionReference<'query'>);
|
||||
const configCodigoBarras = useQuery(api.configuracaoBuscaCodigoBarras.obterConfigBuscaCodigoBarras);
|
||||
|
||||
// Estados do formulário
|
||||
let estoqueMinimoPadrao = $state(10);
|
||||
@@ -22,6 +25,21 @@
|
||||
|
||||
let dataLoaded = $state(false);
|
||||
|
||||
// Estados para configurações de busca de código de barras
|
||||
let gs1BrasilClientId = $state('');
|
||||
let gs1BrasilClientSecret = $state('');
|
||||
let gs1BrasilTokenUrl = $state('https://apicnp.gs1br.org/oauth/token');
|
||||
let gs1BrasilApiUrl = $state('https://apicnp.gs1br.org/api/v1/products');
|
||||
let gs1BrasilAtivo = $state(false);
|
||||
let bluesoftApiKey = $state('');
|
||||
let bluesoftApiUrl = $state('https://api.cosmos.bluesoft.com.br');
|
||||
let bluesoftAtivo = $state(false);
|
||||
let productSearchApiKey = $state('');
|
||||
let productSearchApiUrl = $state('https://api.product-search.net/v1/products');
|
||||
let productSearchAtivo = $state(false);
|
||||
let processandoCodigoBarras = $state(false);
|
||||
let dataLoadedCodigoBarras = $state(false);
|
||||
|
||||
// Carregar config existente
|
||||
$effect(() => {
|
||||
if (configAtual?.data && !dataLoaded) {
|
||||
@@ -37,6 +55,25 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Carregar config de código de barras
|
||||
$effect(() => {
|
||||
if (configCodigoBarras?.data && !dataLoadedCodigoBarras) {
|
||||
const config = configCodigoBarras.data;
|
||||
gs1BrasilClientId = config.gs1BrasilClientId || '';
|
||||
gs1BrasilClientSecret = '';
|
||||
gs1BrasilTokenUrl = config.gs1BrasilTokenUrl || 'https://apicnp.gs1br.org/oauth/token';
|
||||
gs1BrasilApiUrl = config.gs1BrasilApiUrl || 'https://apicnp.gs1br.org/api/v1/products';
|
||||
gs1BrasilAtivo = config.gs1BrasilAtivo;
|
||||
bluesoftApiKey = '';
|
||||
bluesoftApiUrl = config.bluesoftApiUrl || 'https://api.cosmos.bluesoft.com.br';
|
||||
bluesoftAtivo = config.bluesoftAtivo;
|
||||
productSearchApiKey = '';
|
||||
productSearchApiUrl = config.productSearchApiUrl || 'https://api.product-search.net/v1/products';
|
||||
productSearchAtivo = config.productSearchAtivo;
|
||||
dataLoadedCodigoBarras = true;
|
||||
}
|
||||
});
|
||||
|
||||
function mostrarMensagem(tipo: 'success' | 'error', texto: string) {
|
||||
mensagem = { tipo, texto };
|
||||
setTimeout(() => {
|
||||
@@ -98,6 +135,47 @@
|
||||
processando = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function salvarConfiguracaoCodigoBarras() {
|
||||
if (!currentUser?.data?._id) {
|
||||
mostrarMensagem('error', 'Usuário não autenticado');
|
||||
return;
|
||||
}
|
||||
|
||||
processandoCodigoBarras = true;
|
||||
|
||||
try {
|
||||
const resultado = await client.mutation(api.configuracaoBuscaCodigoBarras.salvarConfigBuscaCodigoBarras, {
|
||||
gs1BrasilClientId: gs1BrasilClientId.trim() || undefined,
|
||||
gs1BrasilClientSecret: gs1BrasilClientSecret.trim() || undefined,
|
||||
gs1BrasilTokenUrl: gs1BrasilTokenUrl.trim() || undefined,
|
||||
gs1BrasilApiUrl: gs1BrasilApiUrl.trim() || undefined,
|
||||
gs1BrasilAtivo,
|
||||
bluesoftApiKey: bluesoftApiKey.trim() || undefined,
|
||||
bluesoftApiUrl: bluesoftApiUrl.trim() || undefined,
|
||||
bluesoftAtivo,
|
||||
productSearchApiKey: productSearchApiKey.trim() || undefined,
|
||||
productSearchApiUrl: productSearchApiUrl.trim() || undefined,
|
||||
productSearchAtivo,
|
||||
configuradoPorId: currentUser.data._id
|
||||
});
|
||||
|
||||
if (resultado.sucesso) {
|
||||
mostrarMensagem('success', 'Configurações de código de barras salvas com sucesso!');
|
||||
gs1BrasilClientSecret = '';
|
||||
bluesoftApiKey = '';
|
||||
productSearchApiKey = '';
|
||||
dataLoadedCodigoBarras = false;
|
||||
} else {
|
||||
mostrarMensagem('error', resultado.erro || 'Erro ao salvar configurações');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao salvar configurações:', error);
|
||||
mostrarMensagem('error', 'Erro ao salvar configurações. Tente novamente.');
|
||||
} finally {
|
||||
processandoCodigoBarras = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="container mx-auto px-4 py-4">
|
||||
@@ -352,6 +430,212 @@
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Configurações de Busca de Código de Barras -->
|
||||
<div class="card bg-base-100 shadow-xl mt-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title mb-6 text-2xl">
|
||||
<Barcode class="h-6 w-6" />
|
||||
Configurações de Busca de Código de Barras
|
||||
</h2>
|
||||
|
||||
<!-- Informação sobre APIs Gratuitas -->
|
||||
<div class="alert alert-info mb-6 shadow-lg">
|
||||
<Info class="h-6 w-6 shrink-0" />
|
||||
<div>
|
||||
<p class="font-semibold">APIs Gratuitas Disponíveis</p>
|
||||
<p class="text-sm">
|
||||
As APIs <strong>Busca Unificada</strong> e <strong>Open Food Facts</strong> funcionam sem
|
||||
credenciais e estão sempre disponíveis. Você só precisa configurar as credenciais abaixo
|
||||
se desejar usar as APIs pagas (GS1 Brasil, Bluesoft Cosmo, Product-Search.net).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- GS1 Brasil -->
|
||||
<div class="divider">
|
||||
<div class="flex items-center gap-2">
|
||||
<Key class="h-5 w-5 text-primary" />
|
||||
<span class="font-semibold">GS1 Brasil</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-control mb-4">
|
||||
<label class="label cursor-pointer">
|
||||
<span class="label-text font-medium">Ativar GS1 Brasil</span>
|
||||
<input type="checkbox" class="toggle toggle-primary" bind:checked={gs1BrasilAtivo} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 mb-6">
|
||||
<div class="form-control">
|
||||
<label class="label" for="gs1-client-id">
|
||||
<span class="label-text font-medium">Client ID</span>
|
||||
</label>
|
||||
<input
|
||||
id="gs1-client-id"
|
||||
type="text"
|
||||
bind:value={gs1BrasilClientId}
|
||||
placeholder="Seu Client ID"
|
||||
class="input input-bordered"
|
||||
disabled={!gs1BrasilAtivo}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="gs1-client-secret">
|
||||
<span class="label-text font-medium">Client Secret</span>
|
||||
</label>
|
||||
<input
|
||||
id="gs1-client-secret"
|
||||
type="password"
|
||||
bind:value={gs1BrasilClientSecret}
|
||||
placeholder="Deixe em branco para manter atual"
|
||||
class="input input-bordered"
|
||||
disabled={!gs1BrasilAtivo}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="gs1-token-url">
|
||||
<span class="label-text font-medium">Token URL</span>
|
||||
</label>
|
||||
<input
|
||||
id="gs1-token-url"
|
||||
type="text"
|
||||
bind:value={gs1BrasilTokenUrl}
|
||||
placeholder="https://apicnp.gs1br.org/oauth/token"
|
||||
class="input input-bordered"
|
||||
disabled={!gs1BrasilAtivo}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="gs1-api-url">
|
||||
<span class="label-text font-medium">API URL</span>
|
||||
</label>
|
||||
<input
|
||||
id="gs1-api-url"
|
||||
type="text"
|
||||
bind:value={gs1BrasilApiUrl}
|
||||
placeholder="https://apicnp.gs1br.org/api/v1/products"
|
||||
class="input input-bordered"
|
||||
disabled={!gs1BrasilAtivo}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bluesoft Cosmo -->
|
||||
<div class="divider">
|
||||
<div class="flex items-center gap-2">
|
||||
<Key class="h-5 w-5 text-info" />
|
||||
<span class="font-semibold">Bluesoft Cosmo</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-control mb-4">
|
||||
<label class="label cursor-pointer">
|
||||
<span class="label-text font-medium">Ativar Bluesoft Cosmo</span>
|
||||
<input type="checkbox" class="toggle toggle-info" bind:checked={bluesoftAtivo} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 mb-6">
|
||||
<div class="form-control">
|
||||
<label class="label" for="bluesoft-api-key">
|
||||
<span class="label-text font-medium">API Key</span>
|
||||
</label>
|
||||
<input
|
||||
id="bluesoft-api-key"
|
||||
type="password"
|
||||
bind:value={bluesoftApiKey}
|
||||
placeholder="Deixe em branco para manter atual"
|
||||
class="input input-bordered"
|
||||
disabled={!bluesoftAtivo}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="bluesoft-api-url">
|
||||
<span class="label-text font-medium">API URL</span>
|
||||
</label>
|
||||
<input
|
||||
id="bluesoft-api-url"
|
||||
type="text"
|
||||
bind:value={bluesoftApiUrl}
|
||||
placeholder="https://api.cosmos.bluesoft.io/v1/products"
|
||||
class="input input-bordered"
|
||||
disabled={!bluesoftAtivo}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Product-Search.net -->
|
||||
<div class="divider">
|
||||
<div class="flex items-center gap-2">
|
||||
<Key class="h-5 w-5 text-warning" />
|
||||
<span class="font-semibold">Product-Search.net</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-control mb-4">
|
||||
<label class="label cursor-pointer">
|
||||
<span class="label-text font-medium">Ativar Product-Search.net</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="toggle toggle-warning"
|
||||
bind:checked={productSearchAtivo}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 mb-6">
|
||||
<div class="form-control">
|
||||
<label class="label" for="product-search-api-key">
|
||||
<span class="label-text font-medium">API Key (Opcional)</span>
|
||||
</label>
|
||||
<input
|
||||
id="product-search-api-key"
|
||||
type="password"
|
||||
bind:value={productSearchApiKey}
|
||||
placeholder="Deixe em branco para manter atual"
|
||||
class="input input-bordered"
|
||||
disabled={!productSearchAtivo}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="product-search-api-url">
|
||||
<span class="label-text font-medium">API URL</span>
|
||||
</label>
|
||||
<input
|
||||
id="product-search-api-url"
|
||||
type="text"
|
||||
bind:value={productSearchApiUrl}
|
||||
placeholder="https://api.product-search.net/v1/products"
|
||||
class="input input-bordered"
|
||||
disabled={!productSearchAtivo}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Botões -->
|
||||
<div class="card-actions justify-end mt-6">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onclick={salvarConfiguracaoCodigoBarras}
|
||||
disabled={processandoCodigoBarras}
|
||||
>
|
||||
{#if processandoCodigoBarras}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{:else}
|
||||
<Save class="h-5 w-5" />
|
||||
{/if}
|
||||
Salvar Configurações de Código de Barras
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user