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">
|
<script lang="ts">
|
||||||
import { Image, Upload, X } from 'lucide-svelte';
|
import { Image as ImageIcon, Upload, X, Camera } from 'lucide-svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
value?: string | null;
|
value?: string | null;
|
||||||
@@ -20,6 +20,10 @@
|
|||||||
let preview = $state<string | null>(value);
|
let preview = $state<string | null>(value);
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
let inputElement: HTMLInputElement | 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) {
|
function handleFileSelect(event: Event) {
|
||||||
const target = event.target as HTMLInputElement;
|
const target = event.target as HTMLInputElement;
|
||||||
@@ -73,7 +77,7 @@
|
|||||||
maxHeight: number
|
maxHeight: number
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const img = new Image();
|
const img = new window.Image();
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
let width = img.width;
|
let width = img.width;
|
||||||
let height = img.height;
|
let height = img.height;
|
||||||
@@ -123,8 +127,170 @@
|
|||||||
inputElement?.click();
|
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(() => {
|
$effect(() => {
|
||||||
preview = value;
|
if (value !== preview) {
|
||||||
|
preview = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// Limpar stream quando o componente for desmontado
|
||||||
|
$effect(() => {
|
||||||
|
return () => {
|
||||||
|
stopCamera();
|
||||||
|
};
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -151,23 +317,34 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div
|
<div class="flex flex-col gap-4">
|
||||||
class="border-2 border-dashed border-base-300 rounded-lg p-8 text-center cursor-pointer hover:border-primary transition-colors"
|
<div
|
||||||
onclick={triggerFileInput}
|
class="border-2 border-dashed border-base-300 rounded-lg p-8 text-center cursor-pointer hover:border-primary transition-colors"
|
||||||
role="button"
|
onclick={triggerFileInput}
|
||||||
tabindex="0"
|
role="button"
|
||||||
onkeydown={(e) => {
|
tabindex="0"
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
onkeydown={(e) => {
|
||||||
e.preventDefault();
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
triggerFileInput();
|
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>
|
<Upload class="h-12 w-12 mx-auto mb-4 text-base-content/40" />
|
||||||
<p class="text-sm text-base-content/50">
|
<p class="text-base-content/70 font-medium mb-2">Clique para fazer upload da imagem</p>
|
||||||
PNG, JPG ou GIF até {maxSizeMB}MB
|
<p class="text-sm text-base-content/50">
|
||||||
</p>
|
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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -178,17 +355,89 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if preview}
|
{#if preview}
|
||||||
<button
|
<div class="flex gap-2 mt-4">
|
||||||
type="button"
|
<button
|
||||||
class="btn btn-sm btn-outline btn-primary mt-4"
|
type="button"
|
||||||
onclick={triggerFileInput}
|
class="btn btn-sm btn-outline btn-primary flex-1"
|
||||||
>
|
onclick={triggerFileInput}
|
||||||
<Image class="h-4 w-4" />
|
>
|
||||||
Alterar Imagem
|
<ImageIcon class="h-4 w-4" />
|
||||||
</button>
|
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}
|
{/if}
|
||||||
</div>
|
</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>
|
<style>
|
||||||
.image-upload {
|
.image-upload {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ArrowLeftRight,
|
ArrowLeftRight,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
CheckCircle2
|
CheckCircle2,
|
||||||
|
Settings
|
||||||
} from 'lucide-svelte';
|
} from 'lucide-svelte';
|
||||||
import BarChart3D from '$lib/components/ti/charts/BarChart3D.svelte';
|
import BarChart3D from '$lib/components/ti/charts/BarChart3D.svelte';
|
||||||
|
|
||||||
@@ -291,7 +292,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Ações Rápidas -->
|
<!-- 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
|
<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"
|
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')}
|
onclick={() => goto('/almoxarifado/materiais/cadastro')}
|
||||||
@@ -336,6 +337,21 @@
|
|||||||
<p class="text-base-content/70 text-sm">Visualizar relatórios e estatísticas</p>
|
<p class="text-base-content/70 text-sm">Visualizar relatórios e estatísticas</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useConvexClient } from 'convex-svelte';
|
import { useConvexClient } from 'convex-svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import { 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 BarcodeScanner from '$lib/components/almoxarifado/BarcodeScanner.svelte';
|
||||||
import ImageUpload from '$lib/components/almoxarifado/ImageUpload.svelte';
|
import ImageUpload from '$lib/components/almoxarifado/ImageUpload.svelte';
|
||||||
|
|
||||||
@@ -24,9 +24,24 @@
|
|||||||
let scannerEnabled = $state(false);
|
let scannerEnabled = $state(false);
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let buscandoProduto = $state(false);
|
let buscandoProduto = $state(false);
|
||||||
|
let buscandoExterno = $state(false);
|
||||||
let notice = $state<{ kind: 'success' | 'error'; text: string } | null>(null);
|
let notice = $state<{ kind: 'success' | 'error'; text: string } | null>(null);
|
||||||
let buscaTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
let ultimoCodigoBuscado = $state('');
|
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 unidadesMedida = ['UN', 'CX', 'KG', 'L', 'M', 'M²', 'M³', 'PC', 'DZ'];
|
||||||
const categoriasComuns = [
|
const categoriasComuns = [
|
||||||
@@ -46,6 +61,124 @@
|
|||||||
}, 5000);
|
}, 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) {
|
async function buscarProdutoPorCodigoBarras(barcode: string, mostrarMensagemSucesso = true) {
|
||||||
if (!barcode.trim() || barcode.trim().length < 8) {
|
if (!barcode.trim() || barcode.trim().length < 8) {
|
||||||
return;
|
return;
|
||||||
@@ -58,6 +191,8 @@
|
|||||||
|
|
||||||
buscandoProduto = true;
|
buscandoProduto = true;
|
||||||
ultimoCodigoBuscado = barcode.trim();
|
ultimoCodigoBuscado = barcode.trim();
|
||||||
|
codigoBarrasBuscado = barcode.trim();
|
||||||
|
modalBuscando = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Buscar produto existente pelo código de barras
|
// Buscar produto existente pelo código de barras
|
||||||
@@ -66,6 +201,9 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (materialExistente) {
|
if (materialExistente) {
|
||||||
|
// Fechar modal de busca
|
||||||
|
modalBuscando = false;
|
||||||
|
|
||||||
// Preencher campos automaticamente
|
// Preencher campos automaticamente
|
||||||
codigo = materialExistente.codigo;
|
codigo = materialExistente.codigo;
|
||||||
nome = materialExistente.nome;
|
nome = materialExistente.nome;
|
||||||
@@ -83,14 +221,56 @@
|
|||||||
mostrarMensagem('success', 'Produto encontrado! Campos preenchidos automaticamente.');
|
mostrarMensagem('success', 'Produto encontrado! Campos preenchidos automaticamente.');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Produto não encontrado
|
// Produto não encontrado localmente, buscar externamente
|
||||||
if (mostrarMensagemSucesso) {
|
buscandoExterno = true;
|
||||||
mostrarMensagem('success', 'Código de barras lido. Complete as informações do produto.');
|
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) {
|
} catch (err) {
|
||||||
const errorMessage = err instanceof Error ? err.message : 'Erro ao buscar produto';
|
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
|
// Verificar se é erro de função não encontrada
|
||||||
if (errorMessage.includes('Could not find public function')) {
|
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.';
|
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);
|
await buscarProdutoPorCodigoBarras(barcode, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Busca automática quando código de barras é digitado manualmente
|
// Verificar se deve executar busca automaticamente quando código tem 13 caracteres
|
||||||
$effect(() => {
|
function verificarEExecutarBusca(codigo: string) {
|
||||||
const codigo = codigoBarras.trim();
|
const codigoLimpo = codigo.trim();
|
||||||
|
|
||||||
// Limpar timeout anterior
|
|
||||||
if (buscaTimeout) {
|
|
||||||
clearTimeout(buscaTimeout);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Se o código foi limpo, resetar último código buscado
|
// Se o código foi limpo, resetar último código buscado
|
||||||
if (!codigo) {
|
if (!codigoLimpo) {
|
||||||
ultimoCodigoBuscado = '';
|
ultimoCodigoBuscado = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Aguardar 800ms após parar de digitar antes de buscar
|
// Se o código tem exatamente 13 caracteres (EAN-13), buscar automaticamente
|
||||||
// Isso evita muitas buscas enquanto o usuário está digitando
|
if (codigoLimpo.length === 13) {
|
||||||
buscaTimeout = setTimeout(() => {
|
buscarProdutoPorCodigoBarras(codigoLimpo, false);
|
||||||
// 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);
|
|
||||||
|
|
||||||
return () => {
|
// Handler para tecla Enter no campo de código de barras
|
||||||
if (buscaTimeout) {
|
function handleBarcodeKeydown(event: KeyboardEvent) {
|
||||||
clearTimeout(buscaTimeout);
|
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() {
|
async function handleSubmit() {
|
||||||
// Validação
|
// Validação
|
||||||
@@ -275,20 +452,24 @@
|
|||||||
<div class="form-control md:col-span-1">
|
<div class="form-control md:col-span-1">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text">Código de Barras</span>
|
<span class="label-text">Código de Barras</span>
|
||||||
{#if buscandoProduto}
|
{#if buscandoProduto || buscandoExterno}
|
||||||
<span class="loading loading-spinner loading-xs"></span>
|
<span class="loading loading-spinner loading-xs"></span>
|
||||||
{/if}
|
{/if}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered {buscandoProduto ? 'input-info' : ''}"
|
class="input input-bordered {(buscandoProduto || buscandoExterno) ? 'input-info' : ''}"
|
||||||
placeholder="EAN-13, UPC, etc."
|
placeholder="EAN-13, UPC, etc."
|
||||||
bind:value={codigoBarras}
|
bind:value={codigoBarras}
|
||||||
|
onkeydown={handleBarcodeKeydown}
|
||||||
|
oninput={() => verificarEExecutarBusca(codigoBarras)}
|
||||||
/>
|
/>
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text-alt">
|
<span class="label-text-alt">
|
||||||
{#if buscandoProduto}
|
{#if buscandoProduto}
|
||||||
Buscando produto...
|
Buscando produto no banco de dados...
|
||||||
|
{:else if buscandoExterno}
|
||||||
|
Buscando produto em base externa...
|
||||||
{:else}
|
{:else}
|
||||||
Digite ou use o leitor acima para escanear
|
Digite ou use o leitor acima para escanear
|
||||||
{/if}
|
{/if}
|
||||||
@@ -461,6 +642,288 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</main>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||||
|
import type { FunctionReference } from 'convex/server';
|
||||||
import { useConvexClient, useQuery } from 'convex-svelte';
|
import { useConvexClient, useQuery } from 'convex-svelte';
|
||||||
import { resolve } from '$app/paths';
|
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 client = useConvexClient();
|
||||||
const configAtual = useQuery(api.configuracaoAlmoxarifado.obterConfiguracao, {});
|
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
|
// Estados do formulário
|
||||||
let estoqueMinimoPadrao = $state(10);
|
let estoqueMinimoPadrao = $state(10);
|
||||||
@@ -22,6 +25,21 @@
|
|||||||
|
|
||||||
let dataLoaded = $state(false);
|
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
|
// Carregar config existente
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (configAtual?.data && !dataLoaded) {
|
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) {
|
function mostrarMensagem(tipo: 'success' | 'error', texto: string) {
|
||||||
mensagem = { tipo, texto };
|
mensagem = { tipo, texto };
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -98,6 +135,47 @@
|
|||||||
processando = false;
|
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>
|
</script>
|
||||||
|
|
||||||
<main class="container mx-auto px-4 py-4">
|
<main class="container mx-auto px-4 py-4">
|
||||||
@@ -352,6 +430,212 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</main>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
2
packages/backend/convex/_generated/api.d.ts
vendored
2
packages/backend/convex/_generated/api.d.ts
vendored
@@ -27,6 +27,7 @@ import type * as chamados from "../chamados.js";
|
|||||||
import type * as chat from "../chat.js";
|
import type * as chat from "../chat.js";
|
||||||
import type * as config from "../config.js";
|
import type * as config from "../config.js";
|
||||||
import type * as configuracaoAlmoxarifado from "../configuracaoAlmoxarifado.js";
|
import type * as configuracaoAlmoxarifado from "../configuracaoAlmoxarifado.js";
|
||||||
|
import type * as configuracaoBuscaCodigoBarras from "../configuracaoBuscaCodigoBarras.js";
|
||||||
import type * as configuracaoEmail from "../configuracaoEmail.js";
|
import type * as configuracaoEmail from "../configuracaoEmail.js";
|
||||||
import type * as configuracaoJitsi from "../configuracaoJitsi.js";
|
import type * as configuracaoJitsi from "../configuracaoJitsi.js";
|
||||||
import type * as configuracaoPonto from "../configuracaoPonto.js";
|
import type * as configuracaoPonto from "../configuracaoPonto.js";
|
||||||
@@ -125,6 +126,7 @@ declare const fullApi: ApiFromModules<{
|
|||||||
chat: typeof chat;
|
chat: typeof chat;
|
||||||
config: typeof config;
|
config: typeof config;
|
||||||
configuracaoAlmoxarifado: typeof configuracaoAlmoxarifado;
|
configuracaoAlmoxarifado: typeof configuracaoAlmoxarifado;
|
||||||
|
configuracaoBuscaCodigoBarras: typeof configuracaoBuscaCodigoBarras;
|
||||||
configuracaoEmail: typeof configuracaoEmail;
|
configuracaoEmail: typeof configuracaoEmail;
|
||||||
configuracaoJitsi: typeof configuracaoJitsi;
|
configuracaoJitsi: typeof configuracaoJitsi;
|
||||||
configuracaoPonto: typeof configuracaoPonto;
|
configuracaoPonto: typeof configuracaoPonto;
|
||||||
|
|||||||
196
packages/backend/convex/actions/README_BUSCAR_PRODUTO.md
Normal file
196
packages/backend/convex/actions/README_BUSCAR_PRODUTO.md
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
# Configuração de APIs para Busca de Produtos por Código de Barras
|
||||||
|
|
||||||
|
Este documento descreve como configurar as variáveis de ambiente necessárias para as diferentes APIs de busca de produtos.
|
||||||
|
|
||||||
|
## Fontes de Dados Disponíveis
|
||||||
|
|
||||||
|
O sistema busca produtos em 5 fontes diferentes, em ordem de prioridade:
|
||||||
|
|
||||||
|
1. **GS1 Brasil** (Prioridade 1 - requer credenciais)
|
||||||
|
2. **Bluesoft Cosmo** (Prioridade 2 - requer credenciais)
|
||||||
|
3. **Product-Search.net** (Prioridade 3 - pode requerer credenciais)
|
||||||
|
4. **Busca Unificada** (Prioridade 4 - **GRATUITA, sem credenciais**)
|
||||||
|
5. **Open Food Facts** (Prioridade 5 - **GRATUITA, sem credenciais**)
|
||||||
|
|
||||||
|
## Configuração das APIs
|
||||||
|
|
||||||
|
### 1. GS1 Brasil (API Verified by GS1)
|
||||||
|
|
||||||
|
A GS1 Brasil oferece a API "Verified by GS1" para consulta de produtos cadastrados.
|
||||||
|
|
||||||
|
#### Passos para obter credenciais:
|
||||||
|
|
||||||
|
1. Acesse o portal: https://apicnp.gs1br.org
|
||||||
|
2. Faça login ou crie uma conta
|
||||||
|
3. No menu "Apps", cadastre uma nova aplicação
|
||||||
|
4. Obtenha o `Client_ID` e `Client_Secret`
|
||||||
|
|
||||||
|
#### Configuração no Convex:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Client ID da aplicação registrada
|
||||||
|
npx convex env set GS1_BRASIL_CLIENT_ID "seu-client-id-aqui"
|
||||||
|
|
||||||
|
# Client Secret da aplicação
|
||||||
|
npx convex env set GS1_BRASIL_CLIENT_SECRET "seu-client-secret-aqui"
|
||||||
|
|
||||||
|
# URL do token (opcional, padrão já configurado)
|
||||||
|
npx convex env set GS1_BRASIL_TOKEN_URL "https://apicnp.gs1br.org/oauth/token"
|
||||||
|
|
||||||
|
# URL da API (opcional, padrão já configurado)
|
||||||
|
npx convex env set GS1_BRASIL_API_URL "https://apicnp.gs1br.org/api/v1/products"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Documentação:
|
||||||
|
- Manual: https://www.gs1br.org/educacao-e-eventos/Documents/Manual%20do%20Usu%C3%A1rio%20-%20API%20Verified%20by%20GS1_v.03.pdf
|
||||||
|
- Portal: https://apicnp.gs1br.org
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Bluesoft Cosmos
|
||||||
|
|
||||||
|
A Bluesoft oferece uma API REST para consulta de produtos por código de barras (GTIN/EAN).
|
||||||
|
|
||||||
|
#### Passos para obter credenciais:
|
||||||
|
|
||||||
|
1. Acesse: https://api.cosmos.bluesoft.com.br/api
|
||||||
|
2. Faça login na plataforma
|
||||||
|
3. Obtenha seu token de API (X-Cosmos-Token) e User-Agent
|
||||||
|
4. Ambos estarão disponíveis na página após fazer login
|
||||||
|
|
||||||
|
#### Configuração no Convex:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Token de autenticação da Bluesoft (X-Cosmos-Token)
|
||||||
|
npx convex env set BLUESOFT_API_KEY "seu-token-aqui"
|
||||||
|
|
||||||
|
# URL da API (opcional, padrão já configurado)
|
||||||
|
npx convex env set BLUESOFT_API_URL "https://api.cosmos.bluesoft.com.br"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Importante**: O sistema usa automaticamente o endpoint correto `/gtins/{codigo}.json` conforme a documentação oficial.
|
||||||
|
|
||||||
|
#### Endpoint:
|
||||||
|
- **GET** `/gtins/{código}.json` - Recupera detalhes do produto através do GTIN/EAN informado
|
||||||
|
|
||||||
|
#### Estrutura de Resposta:
|
||||||
|
A API retorna informações incluindo:
|
||||||
|
- `description`: Nome/descrição do produto
|
||||||
|
- `brand.name`: Nome da marca
|
||||||
|
- `gpc.description`: Categoria do produto
|
||||||
|
- `ncm.code` e `ncm.description`: Código NCM e descrição
|
||||||
|
- `thumbnail`: URL da imagem do produto
|
||||||
|
- `price`: Preço formatado
|
||||||
|
- `gross_weight` e `net_weight`: Peso bruto e líquido
|
||||||
|
|
||||||
|
#### Documentação:
|
||||||
|
- **Documentação Oficial**: https://api.cosmos.bluesoft.com.br/api
|
||||||
|
- Central de Ajuda: https://ajuda.bluesoft.com.br/
|
||||||
|
- Suporte: suporte.api@bluesoft.com.br
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Product-Search.net
|
||||||
|
|
||||||
|
Plataforma que fornece informações sobre produtos com base em códigos de barras.
|
||||||
|
|
||||||
|
#### Passos para obter credenciais (opcional):
|
||||||
|
|
||||||
|
1. Acesse: https://product-search.net
|
||||||
|
2. Crie uma conta (se necessário)
|
||||||
|
3. Obtenha sua API key (se disponível no seu plano)
|
||||||
|
|
||||||
|
#### Configuração no Convex:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# API Key (opcional - pode funcionar sem autenticação dependendo do plano)
|
||||||
|
npx convex env set PRODUCT_SEARCH_API_KEY "sua-api-key-aqui"
|
||||||
|
|
||||||
|
# URL da API (opcional, padrão já configurado)
|
||||||
|
npx convex env set PRODUCT_SEARCH_API_URL "https://api.product-search.net/v1/products"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Documentação:
|
||||||
|
- Site: https://product-search.net
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Busca Unificada
|
||||||
|
|
||||||
|
API gratuita e pública para busca de produtos por código de barras. **Não requer configuração ou credenciais.**
|
||||||
|
|
||||||
|
#### Características:
|
||||||
|
|
||||||
|
- ✅ Totalmente gratuita para uso pessoal e comercial
|
||||||
|
- ✅ Sem necessidade de cadastro ou credenciais
|
||||||
|
- ✅ Sempre disponível
|
||||||
|
- ✅ Foco em produtos brasileiros
|
||||||
|
|
||||||
|
#### Documentação:
|
||||||
|
- Site: https://api-produtos.seunegocionanuvem.com.br
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Open Food Facts
|
||||||
|
|
||||||
|
API gratuita e pública, não requer configuração. Sempre disponível como fallback.
|
||||||
|
|
||||||
|
#### Características:
|
||||||
|
|
||||||
|
- ✅ Totalmente gratuita e colaborativa
|
||||||
|
- ✅ Sem necessidade de cadastro ou credenciais
|
||||||
|
- ✅ Foco em produtos alimentícios
|
||||||
|
- ✅ Base de dados colaborativa internacional
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ordem de Prioridade
|
||||||
|
|
||||||
|
O sistema tenta buscar em todas as fontes em paralelo e retorna o primeiro resultado válido encontrado, seguindo esta ordem de prioridade:
|
||||||
|
|
||||||
|
1. **GS1 Brasil** (requer credenciais)
|
||||||
|
2. **Bluesoft Cosmo** (requer credenciais)
|
||||||
|
3. **Product-Search.net** (pode requerer credenciais)
|
||||||
|
4. **Busca Unificada** (gratuita, sem credenciais) ⭐
|
||||||
|
5. **Open Food Facts** (gratuita, sem credenciais) ⭐
|
||||||
|
|
||||||
|
> ⭐ **Nota**: As APIs gratuitas (Busca Unificada e Open Food Facts) funcionam sem nenhuma configuração adicional e estão sempre disponíveis.
|
||||||
|
|
||||||
|
## Verificação da Configuração
|
||||||
|
|
||||||
|
Para verificar se as variáveis de ambiente estão configuradas:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx convex env list
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas Importantes
|
||||||
|
|
||||||
|
1. **Limites de Uso**: Cada API pode ter limites de requisições por período. Consulte a documentação de cada serviço.
|
||||||
|
|
||||||
|
2. **Custos**: Algumas APIs podem ter custos associados. Verifique os planos disponíveis.
|
||||||
|
|
||||||
|
3. **Segurança**: As credenciais são armazenadas de forma segura nas variáveis de ambiente do Convex e nunca são expostas ao frontend.
|
||||||
|
|
||||||
|
4. **Fallback**: Se uma API falhar ou não estiver configurada, o sistema automaticamente tenta as outras fontes. As APIs gratuitas (Busca Unificada e Open Food Facts) sempre estarão disponíveis mesmo sem configuração.
|
||||||
|
|
||||||
|
5. **Timeout**: Todas as requisições têm timeout de 5 segundos para evitar travamentos.
|
||||||
|
|
||||||
|
## Testando a Configuração
|
||||||
|
|
||||||
|
Após configurar as variáveis de ambiente, teste a busca com um código de barras conhecido:
|
||||||
|
|
||||||
|
1. Acesse a página de cadastro de materiais
|
||||||
|
2. Digite ou escaneie um código de barras
|
||||||
|
3. O sistema deve buscar em todas as fontes configuradas
|
||||||
|
4. O modal mostrará a fonte dos dados encontrados
|
||||||
|
|
||||||
|
## Suporte
|
||||||
|
|
||||||
|
Em caso de problemas:
|
||||||
|
|
||||||
|
1. Verifique se as credenciais estão corretas
|
||||||
|
2. Verifique se as URLs dos endpoints estão corretas
|
||||||
|
3. Consulte os logs do Convex para erros específicos
|
||||||
|
4. Verifique a documentação oficial de cada API
|
||||||
|
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import { action } from '../_generated/server';
|
import { action } from '../_generated/server';
|
||||||
import { v } from 'convex/values';
|
import { v } from 'convex/values';
|
||||||
|
import { api, internal } from '../_generated/api';
|
||||||
|
import { decryptSMTPPassword } from '../auth/utils';
|
||||||
|
|
||||||
interface OpenFoodFactsProduct {
|
interface OpenFoodFactsProduct {
|
||||||
product?: {
|
product?: {
|
||||||
@@ -22,6 +24,71 @@ interface OpenFoodFactsProduct {
|
|||||||
status_verbose?: string;
|
status_verbose?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface GS1BrasilProduct {
|
||||||
|
gtin?: string;
|
||||||
|
description?: string;
|
||||||
|
brand?: string;
|
||||||
|
brandName?: string;
|
||||||
|
category?: string;
|
||||||
|
categoryName?: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
image?: string;
|
||||||
|
ncm?: string;
|
||||||
|
unidadeMedida?: string;
|
||||||
|
pesoBruto?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BluesoftProduct {
|
||||||
|
gtin?: number | string;
|
||||||
|
description?: string;
|
||||||
|
brand?: {
|
||||||
|
name?: string;
|
||||||
|
picture?: string;
|
||||||
|
};
|
||||||
|
gpc?: {
|
||||||
|
code?: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
ncm?: {
|
||||||
|
code?: string;
|
||||||
|
description?: string;
|
||||||
|
full_description?: string;
|
||||||
|
};
|
||||||
|
thumbnail?: string;
|
||||||
|
price?: string;
|
||||||
|
avg_price?: number;
|
||||||
|
max_price?: number;
|
||||||
|
gross_weight?: number;
|
||||||
|
net_weight?: number;
|
||||||
|
height?: number;
|
||||||
|
width?: number;
|
||||||
|
length?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProductSearchProduct {
|
||||||
|
gtin?: string;
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
brand?: string;
|
||||||
|
category?: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
image?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BuscaUnificadaProduct {
|
||||||
|
produto?: {
|
||||||
|
nome?: string;
|
||||||
|
descricao?: string;
|
||||||
|
categoria?: string;
|
||||||
|
marca?: string;
|
||||||
|
imagem?: string;
|
||||||
|
imagemUrl?: string;
|
||||||
|
ean?: string;
|
||||||
|
};
|
||||||
|
sucesso?: boolean;
|
||||||
|
mensagem?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface ProductInfo {
|
interface ProductInfo {
|
||||||
nome?: string;
|
nome?: string;
|
||||||
descricao?: string;
|
descricao?: string;
|
||||||
@@ -30,13 +97,36 @@ interface ProductInfo {
|
|||||||
marca?: string;
|
marca?: string;
|
||||||
quantidade?: string;
|
quantidade?: string;
|
||||||
embalagem?: string;
|
embalagem?: string;
|
||||||
|
fonte?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GS1TokenResponse {
|
||||||
|
access_token?: string;
|
||||||
|
token_type?: string;
|
||||||
|
expires_in?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Busca informações de produto via API externa (Open Food Facts)
|
* Busca informações de produto via múltiplas APIs externas
|
||||||
* Esta é uma funcionalidade opcional que pode ser usada para preencher
|
* Fontes: Open Food Facts, Busca Unificada, GS1 Brasil, Bluesoft Cosmo, Product-Search.net
|
||||||
* automaticamente informações de produtos quando disponível.
|
*
|
||||||
|
* A busca é feita em paralelo em todas as fontes disponíveis e retorna
|
||||||
|
* o primeiro resultado válido encontrado.
|
||||||
*/
|
*/
|
||||||
|
interface ConfigBuscaCodigoBarras {
|
||||||
|
gs1BrasilClientId?: string;
|
||||||
|
gs1BrasilClientSecret?: string;
|
||||||
|
gs1BrasilTokenUrl?: string;
|
||||||
|
gs1BrasilApiUrl?: string;
|
||||||
|
gs1BrasilAtivo: boolean;
|
||||||
|
bluesoftApiKey?: string;
|
||||||
|
bluesoftApiUrl?: string;
|
||||||
|
bluesoftAtivo: boolean;
|
||||||
|
productSearchApiKey?: string;
|
||||||
|
productSearchApiUrl?: string;
|
||||||
|
productSearchAtivo: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export const buscarInfoProdutoPorCodigoBarras = action({
|
export const buscarInfoProdutoPorCodigoBarras = action({
|
||||||
args: {
|
args: {
|
||||||
codigoBarras: v.string()
|
codigoBarras: v.string()
|
||||||
@@ -49,72 +139,484 @@ export const buscarInfoProdutoPorCodigoBarras = action({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// Obter configurações do banco de dados
|
||||||
// Tentar buscar na API Open Food Facts (gratuita, sem autenticação)
|
const configDb = await ctx.runQuery(
|
||||||
const response = await fetch(
|
internal.configuracaoBuscaCodigoBarras.obterConfigBuscaCodigoBarrasInternal
|
||||||
`https://world.openfoodfacts.org/api/v0/product/${codigoBarras}.json`,
|
);
|
||||||
{
|
|
||||||
method: 'GET',
|
// Preparar configurações (com descriptografia de credenciais)
|
||||||
headers: {
|
let config: ConfigBuscaCodigoBarras = {
|
||||||
'User-Agent': 'SGSE-App/1.0 (Almoxarifado)'
|
gs1BrasilAtivo: false,
|
||||||
},
|
bluesoftAtivo: false,
|
||||||
signal: AbortSignal.timeout(5000) // Timeout de 5 segundos
|
productSearchAtivo: false
|
||||||
|
};
|
||||||
|
|
||||||
|
if (configDb) {
|
||||||
|
// Descriptografar credenciais se existirem
|
||||||
|
let gs1BrasilClientSecret: string | undefined;
|
||||||
|
if (configDb.gs1BrasilClientSecret) {
|
||||||
|
try {
|
||||||
|
gs1BrasilClientSecret = await decryptSMTPPassword(configDb.gs1BrasilClientSecret);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao descriptografar GS1 Client Secret:', error);
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = (await response.json()) as OpenFoodFactsProduct;
|
let bluesoftApiKey: string | undefined;
|
||||||
|
if (configDb.bluesoftApiKey) {
|
||||||
if (data.status !== 1 || !data.product) {
|
try {
|
||||||
return null;
|
bluesoftApiKey = await decryptSMTPPassword(configDb.bluesoftApiKey);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao descriptografar Bluesoft API Key:', error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const product = data.product;
|
let productSearchApiKey: string | undefined;
|
||||||
|
if (configDb.productSearchApiKey) {
|
||||||
// Extrair categoria (primeira categoria disponível)
|
try {
|
||||||
let categoria: string | undefined;
|
productSearchApiKey = await decryptSMTPPassword(configDb.productSearchApiKey);
|
||||||
if (product.categories_tags && product.categories_tags.length > 0) {
|
} catch (error) {
|
||||||
// Pegar a primeira categoria e limpar tags
|
console.error('Erro ao descriptografar Product-Search API Key:', error);
|
||||||
const primeiraCategoria = product.categories_tags[0];
|
}
|
||||||
categoria = primeiraCategoria
|
|
||||||
.replace(/^pt:/, '')
|
|
||||||
.replace(/^en:/, '')
|
|
||||||
.replace(/-/g, ' ')
|
|
||||||
.split(' ')
|
|
||||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
||||||
.join(' ');
|
|
||||||
} else if (product.categories) {
|
|
||||||
categoria = product.categories.split(',')[0].trim();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const info: ProductInfo = {
|
config = {
|
||||||
nome: product.product_name_pt || product.product_name || undefined,
|
gs1BrasilClientId: configDb.gs1BrasilClientId,
|
||||||
descricao: product.generic_name_pt || product.generic_name || undefined,
|
gs1BrasilClientSecret,
|
||||||
categoria,
|
gs1BrasilTokenUrl: configDb.gs1BrasilTokenUrl,
|
||||||
imagemUrl:
|
gs1BrasilApiUrl: configDb.gs1BrasilApiUrl,
|
||||||
product.image_front_url ||
|
gs1BrasilAtivo: configDb.gs1BrasilAtivo,
|
||||||
product.image_url ||
|
bluesoftApiKey,
|
||||||
product.image_front_small_url ||
|
bluesoftApiUrl: configDb.bluesoftApiUrl,
|
||||||
undefined,
|
bluesoftAtivo: configDb.bluesoftAtivo,
|
||||||
marca: product.brands || undefined,
|
productSearchApiKey,
|
||||||
quantidade: product.quantity || undefined,
|
productSearchApiUrl: configDb.productSearchApiUrl,
|
||||||
embalagem: product.packaging || undefined
|
productSearchAtivo: configDb.productSearchAtivo
|
||||||
};
|
};
|
||||||
|
|
||||||
// Retornar apenas se tiver pelo menos nome ou descrição
|
|
||||||
if (info.nome || info.descricao) {
|
|
||||||
return info;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
} catch (error) {
|
|
||||||
// Log do erro mas não falhar a operação
|
|
||||||
console.error('Erro ao buscar informações do produto:', error);
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tentar buscar em todas as fontes em paralelo
|
||||||
|
// Usamos Promise.allSettled para não falhar se uma fonte falhar
|
||||||
|
const resultados = await Promise.allSettled([
|
||||||
|
buscarOpenFoodFacts(codigoBarras), // 0
|
||||||
|
buscarBuscaUnificada(codigoBarras), // 1 - Gratuita, sem credenciais
|
||||||
|
buscarGS1Brasil(codigoBarras, config), // 2
|
||||||
|
buscarBluesoftCosmo(codigoBarras, config), // 3
|
||||||
|
buscarProductSearch(codigoBarras, config) // 4
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Processar resultados e retornar o primeiro que tiver dados válidos
|
||||||
|
// Prioridade: GS1 Brasil > Bluesoft > Product-Search > Busca Unificada > Open Food Facts
|
||||||
|
const ordemPrioridade = [2, 3, 4, 1, 0]; // Índices das fontes por prioridade
|
||||||
|
|
||||||
|
for (const indice of ordemPrioridade) {
|
||||||
|
const resultado = resultados[indice];
|
||||||
|
if (resultado.status === 'fulfilled' && resultado.value) {
|
||||||
|
return resultado.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se nenhuma fonte retornou dados, retornar null
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Busca na API de Produtos por Código de Barras (Busca Unificada)
|
||||||
|
* Gratuita, sem necessidade de credenciais
|
||||||
|
* Site: https://api-produtos.seunegocionanuvem.com.br
|
||||||
|
*/
|
||||||
|
async function buscarBuscaUnificada(codigoBarras: string): Promise<ProductInfo | null> {
|
||||||
|
try {
|
||||||
|
const apiUrl = 'https://api-produtos.seunegocionanuvem.com.br/api/produtos';
|
||||||
|
const response = await fetch(`${apiUrl}?ean=${codigoBarras}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'User-Agent': 'SGSE-App/1.0 (Almoxarifado)'
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(5000)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as BuscaUnificadaProduct;
|
||||||
|
|
||||||
|
if (!data.produto || (!data.produto.nome && !data.produto.descricao)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const produto = data.produto;
|
||||||
|
|
||||||
|
const info: ProductInfo = {
|
||||||
|
nome: produto.nome || undefined,
|
||||||
|
descricao: produto.descricao || undefined,
|
||||||
|
categoria: produto.categoria || undefined,
|
||||||
|
imagemUrl: produto.imagemUrl || produto.imagem || undefined,
|
||||||
|
marca: produto.marca || undefined,
|
||||||
|
fonte: 'Busca Unificada'
|
||||||
|
};
|
||||||
|
|
||||||
|
if (info.nome || info.descricao) {
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao buscar Busca Unificada:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Busca na Open Food Facts (gratuita, sem autenticação)
|
||||||
|
*/
|
||||||
|
async function buscarOpenFoodFacts(codigoBarras: string): Promise<ProductInfo | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`https://world.openfoodfacts.org/api/v0/product/${codigoBarras}.json`,
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'SGSE-App/1.0 (Almoxarifado)'
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(5000)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as OpenFoodFactsProduct;
|
||||||
|
|
||||||
|
if (data.status !== 1 || !data.product) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const product = data.product;
|
||||||
|
|
||||||
|
// Extrair categoria
|
||||||
|
let categoria: string | undefined;
|
||||||
|
if (product.categories_tags && product.categories_tags.length > 0) {
|
||||||
|
const primeiraCategoria = product.categories_tags[0];
|
||||||
|
categoria = primeiraCategoria
|
||||||
|
.replace(/^pt:/, '')
|
||||||
|
.replace(/^en:/, '')
|
||||||
|
.replace(/-/g, ' ')
|
||||||
|
.split(' ')
|
||||||
|
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||||
|
.join(' ');
|
||||||
|
} else if (product.categories) {
|
||||||
|
categoria = product.categories.split(',')[0].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const info: ProductInfo = {
|
||||||
|
nome: product.product_name_pt || product.product_name || undefined,
|
||||||
|
descricao: product.generic_name_pt || product.generic_name || undefined,
|
||||||
|
categoria,
|
||||||
|
imagemUrl:
|
||||||
|
product.image_front_url || product.image_url || product.image_front_small_url || undefined,
|
||||||
|
marca: product.brands || undefined,
|
||||||
|
quantidade: product.quantity || undefined,
|
||||||
|
embalagem: product.packaging || undefined,
|
||||||
|
fonte: 'Open Food Facts'
|
||||||
|
};
|
||||||
|
|
||||||
|
if (info.nome || info.descricao) {
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao buscar Open Food Facts:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtém token de autenticação da GS1 Brasil
|
||||||
|
*/
|
||||||
|
async function obterTokenGS1Brasil(config: ConfigBuscaCodigoBarras): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
// Priorizar configurações do banco, fallback para variáveis de ambiente
|
||||||
|
const clientId = config.gs1BrasilClientId || process.env.GS1_BRASIL_CLIENT_ID;
|
||||||
|
const clientSecret = config.gs1BrasilClientSecret || process.env.GS1_BRASIL_CLIENT_SECRET;
|
||||||
|
const tokenUrl =
|
||||||
|
config.gs1BrasilTokenUrl ||
|
||||||
|
process.env.GS1_BRASIL_TOKEN_URL ||
|
||||||
|
'https://apicnp.gs1br.org/oauth/token';
|
||||||
|
|
||||||
|
if (!clientId || !clientSecret) {
|
||||||
|
console.warn('GS1 Brasil: Credenciais não configuradas');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(tokenUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: 'client_credentials',
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: clientSecret
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(5000)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('GS1 Brasil: Erro ao obter token', response.status);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as GS1TokenResponse;
|
||||||
|
return data.access_token || null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('GS1 Brasil: Erro ao obter token:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Busca na GS1 Brasil (API Verified by GS1)
|
||||||
|
*/
|
||||||
|
async function buscarGS1Brasil(
|
||||||
|
codigoBarras: string,
|
||||||
|
config: ConfigBuscaCodigoBarras
|
||||||
|
): Promise<ProductInfo | null> {
|
||||||
|
try {
|
||||||
|
if (!config.gs1BrasilAtivo) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await obterTokenGS1Brasil(config);
|
||||||
|
if (!token) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiUrl =
|
||||||
|
config.gs1BrasilApiUrl ||
|
||||||
|
process.env.GS1_BRASIL_API_URL ||
|
||||||
|
'https://apicnp.gs1br.org/api/v1/products';
|
||||||
|
const response = await fetch(`${apiUrl}/${codigoBarras}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'User-Agent': 'SGSE-App/1.0 (Almoxarifado)'
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(5000)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 404) {
|
||||||
|
return null; // Produto não encontrado
|
||||||
|
}
|
||||||
|
console.error('GS1 Brasil: Erro na consulta', response.status);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as GS1BrasilProduct;
|
||||||
|
|
||||||
|
if (!data.gtin && !data.description) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const info: ProductInfo = {
|
||||||
|
nome: data.description || undefined,
|
||||||
|
descricao: data.description || undefined,
|
||||||
|
categoria: data.categoryName || data.category || undefined,
|
||||||
|
imagemUrl: data.imageUrl || data.image || undefined,
|
||||||
|
marca: data.brandName || data.brand || undefined,
|
||||||
|
quantidade: data.unidadeMedida || undefined,
|
||||||
|
fonte: 'GS1 Brasil'
|
||||||
|
};
|
||||||
|
|
||||||
|
if (info.nome || info.descricao) {
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao buscar GS1 Brasil:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Busca na Bluesoft Cosmos
|
||||||
|
* Documentação oficial: https://api.cosmos.bluesoft.com.br/api
|
||||||
|
* Endpoint: GET /gtins/{código}.json
|
||||||
|
*/
|
||||||
|
async function buscarBluesoftCosmo(
|
||||||
|
codigoBarras: string,
|
||||||
|
config: ConfigBuscaCodigoBarras
|
||||||
|
): Promise<ProductInfo | null> {
|
||||||
|
try {
|
||||||
|
if (!config.bluesoftAtivo) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priorizar configurações do banco, fallback para variáveis de ambiente
|
||||||
|
const apiKey = config.bluesoftApiKey || process.env.BLUESOFT_API_KEY;
|
||||||
|
|
||||||
|
// Endpoint base da API - pode ser configurado, mas o padrão é o oficial
|
||||||
|
const apiBaseUrl =
|
||||||
|
config.bluesoftApiUrl ||
|
||||||
|
process.env.BLUESOFT_API_URL ||
|
||||||
|
'https://api.cosmos.bluesoft.com.br';
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
console.warn('Bluesoft Cosmos: API Key não configurada');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Endpoint correto conforme documentação: /gtins/{codigo}.json
|
||||||
|
const url = `${apiBaseUrl.replace(/\/$/, '')}/gtins/${codigoBarras}.json`;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'X-Cosmos-Token': apiKey,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'User-Agent': 'SGSE-App/1.0 (Almoxarifado)' // User-Agent é obrigatório
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(10000) // 10 segundos de timeout
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text().catch(() => 'Não foi possível ler resposta');
|
||||||
|
|
||||||
|
if (response.status === 404) {
|
||||||
|
// Produto não encontrado é normal, não é erro
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
console.error('Bluesoft Cosmos: Erro de autenticação. Verifique a API Key e User-Agent.');
|
||||||
|
console.error('Bluesoft Cosmos: Detalhes do erro', {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
body: errorText.substring(0, 500)
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 429) {
|
||||||
|
console.error('Bluesoft Cosmos: Limite de requisições excedido (HTTP 429)');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('Bluesoft Cosmos: Erro na consulta', {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
url,
|
||||||
|
body: errorText.substring(0, 500)
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as BluesoftProduct;
|
||||||
|
|
||||||
|
// Validar se temos dados mínimos
|
||||||
|
if (!data.gtin && !data.description) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mapear campos conforme estrutura da API
|
||||||
|
const info: ProductInfo = {
|
||||||
|
nome: data.description || undefined,
|
||||||
|
descricao: data.description || undefined,
|
||||||
|
categoria: data.gpc?.description || data.ncm?.description || undefined,
|
||||||
|
imagemUrl: data.thumbnail || undefined,
|
||||||
|
marca: data.brand?.name || undefined,
|
||||||
|
quantidade: data.net_weight ? `${data.net_weight}g` : data.gross_weight ? `${data.gross_weight}g` : undefined,
|
||||||
|
fonte: 'Bluesoft Cosmos'
|
||||||
|
};
|
||||||
|
|
||||||
|
if (info.nome || info.descricao) {
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Bluesoft Cosmos: Erro ao buscar:', error);
|
||||||
|
if (error instanceof Error) {
|
||||||
|
console.error('Bluesoft Cosmos: Detalhes do erro:', error.message);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Busca no Product-Search.net
|
||||||
|
* Pode funcionar com ou sem API key dependendo do plano
|
||||||
|
*/
|
||||||
|
async function buscarProductSearch(
|
||||||
|
codigoBarras: string,
|
||||||
|
config: ConfigBuscaCodigoBarras
|
||||||
|
): Promise<ProductInfo | null> {
|
||||||
|
try {
|
||||||
|
if (!config.productSearchAtivo) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priorizar configurações do banco, fallback para variáveis de ambiente
|
||||||
|
const apiKey = config.productSearchApiKey || process.env.PRODUCT_SEARCH_API_KEY;
|
||||||
|
const apiUrl =
|
||||||
|
config.productSearchApiUrl ||
|
||||||
|
process.env.PRODUCT_SEARCH_API_URL ||
|
||||||
|
'https://api.product-search.net/v1/products';
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'User-Agent': 'SGSE-App/1.0 (Almoxarifado)'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Adicionar API key se disponível
|
||||||
|
if (apiKey) {
|
||||||
|
headers['Authorization'] = `Bearer ${apiKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${apiUrl}/${codigoBarras}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers,
|
||||||
|
signal: AbortSignal.timeout(5000)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 404) {
|
||||||
|
return null; // Produto não encontrado
|
||||||
|
}
|
||||||
|
console.error('Product-Search.net: Erro na consulta', response.status);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as ProductSearchProduct;
|
||||||
|
|
||||||
|
if (!data.gtin && !data.name && !data.description) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const info: ProductInfo = {
|
||||||
|
nome: data.name || data.description || undefined,
|
||||||
|
descricao: data.description || undefined,
|
||||||
|
categoria: data.category || undefined,
|
||||||
|
imagemUrl: data.imageUrl || data.image || undefined,
|
||||||
|
marca: data.brand || undefined,
|
||||||
|
fonte: 'Product-Search.net'
|
||||||
|
};
|
||||||
|
|
||||||
|
if (info.nome || info.descricao) {
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao buscar Product-Search.net:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
166
packages/backend/convex/configuracaoBuscaCodigoBarras.ts
Normal file
166
packages/backend/convex/configuracaoBuscaCodigoBarras.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { v } from 'convex/values';
|
||||||
|
import { mutation, query, internalQuery } from './_generated/server';
|
||||||
|
import { encryptSMTPPassword } from './auth/utils';
|
||||||
|
import { registrarAtividade } from './logsAtividades';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obter configuração de busca de código de barras ativa (credenciais mascaradas)
|
||||||
|
*/
|
||||||
|
export const obterConfigBuscaCodigoBarras = query({
|
||||||
|
args: {},
|
||||||
|
handler: async (ctx) => {
|
||||||
|
const config = await ctx.db
|
||||||
|
.query('configuracaoBuscaCodigoBarras')
|
||||||
|
.withIndex('by_ativo', (q) => q.eq('ativo', true))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retornar config com credenciais mascaradas
|
||||||
|
return {
|
||||||
|
_id: config._id,
|
||||||
|
// GS1 Brasil
|
||||||
|
gs1BrasilClientId: config.gs1BrasilClientId || '',
|
||||||
|
gs1BrasilClientSecret: '********', // Mascarar
|
||||||
|
gs1BrasilTokenUrl: config.gs1BrasilTokenUrl || '',
|
||||||
|
gs1BrasilApiUrl: config.gs1BrasilApiUrl || '',
|
||||||
|
gs1BrasilAtivo: config.gs1BrasilAtivo,
|
||||||
|
// Bluesoft Cosmo
|
||||||
|
bluesoftApiKey: '********', // Mascarar
|
||||||
|
bluesoftApiUrl: config.bluesoftApiUrl || '',
|
||||||
|
bluesoftAtivo: config.bluesoftAtivo,
|
||||||
|
// Product-Search.net
|
||||||
|
productSearchApiKey: '********', // Mascarar
|
||||||
|
productSearchApiUrl: config.productSearchApiUrl || '',
|
||||||
|
productSearchAtivo: config.productSearchAtivo,
|
||||||
|
ativo: config.ativo,
|
||||||
|
atualizadoEm: config.atualizadoEm
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Salvar configuração de busca de código de barras
|
||||||
|
*/
|
||||||
|
export const salvarConfigBuscaCodigoBarras = mutation({
|
||||||
|
args: {
|
||||||
|
// GS1 Brasil
|
||||||
|
gs1BrasilClientId: v.optional(v.string()),
|
||||||
|
gs1BrasilClientSecret: v.optional(v.string()),
|
||||||
|
gs1BrasilTokenUrl: v.optional(v.string()),
|
||||||
|
gs1BrasilApiUrl: v.optional(v.string()),
|
||||||
|
gs1BrasilAtivo: v.boolean(),
|
||||||
|
// Bluesoft Cosmo
|
||||||
|
bluesoftApiKey: v.optional(v.string()),
|
||||||
|
bluesoftApiUrl: v.optional(v.string()),
|
||||||
|
bluesoftAtivo: v.boolean(),
|
||||||
|
// Product-Search.net
|
||||||
|
productSearchApiKey: v.optional(v.string()),
|
||||||
|
productSearchApiUrl: v.optional(v.string()),
|
||||||
|
productSearchAtivo: v.boolean(),
|
||||||
|
configuradoPorId: v.id('usuarios')
|
||||||
|
},
|
||||||
|
returns: v.union(
|
||||||
|
v.object({ sucesso: v.literal(true), configId: v.id('configuracaoBuscaCodigoBarras') }),
|
||||||
|
v.object({ sucesso: v.literal(false), erro: v.string() })
|
||||||
|
),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
// Buscar config ativa anterior para manter credenciais se não fornecidas
|
||||||
|
const configAtiva = await ctx.db
|
||||||
|
.query('configuracaoBuscaCodigoBarras')
|
||||||
|
.withIndex('by_ativo', (q) => q.eq('ativo', true))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
// Determinar credenciais: usar novas se fornecidas, senão manter as atuais
|
||||||
|
let gs1BrasilClientSecret: string | undefined;
|
||||||
|
if (args.gs1BrasilClientSecret && args.gs1BrasilClientSecret.trim().length > 0) {
|
||||||
|
// Nova credencial fornecida, criptografar
|
||||||
|
gs1BrasilClientSecret = await encryptSMTPPassword(args.gs1BrasilClientSecret);
|
||||||
|
} else if (configAtiva?.gs1BrasilClientSecret) {
|
||||||
|
// Credencial não fornecida, manter a atual
|
||||||
|
gs1BrasilClientSecret = configAtiva.gs1BrasilClientSecret;
|
||||||
|
}
|
||||||
|
|
||||||
|
let bluesoftApiKey: string | undefined;
|
||||||
|
if (args.bluesoftApiKey && args.bluesoftApiKey.trim().length > 0) {
|
||||||
|
bluesoftApiKey = await encryptSMTPPassword(args.bluesoftApiKey);
|
||||||
|
} else if (configAtiva?.bluesoftApiKey) {
|
||||||
|
bluesoftApiKey = configAtiva.bluesoftApiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
let productSearchApiKey: string | undefined;
|
||||||
|
if (args.productSearchApiKey && args.productSearchApiKey.trim().length > 0) {
|
||||||
|
productSearchApiKey = await encryptSMTPPassword(args.productSearchApiKey);
|
||||||
|
} else if (configAtiva?.productSearchApiKey) {
|
||||||
|
productSearchApiKey = configAtiva.productSearchApiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Desativar config anterior
|
||||||
|
const configsAntigas = await ctx.db
|
||||||
|
.query('configuracaoBuscaCodigoBarras')
|
||||||
|
.withIndex('by_ativo', (q) => q.eq('ativo', true))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for (const config of configsAntigas) {
|
||||||
|
await ctx.db.patch(config._id, { ativo: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Criar nova config
|
||||||
|
const configId = await ctx.db.insert('configuracaoBuscaCodigoBarras', {
|
||||||
|
gs1BrasilClientId: args.gs1BrasilClientId || undefined,
|
||||||
|
gs1BrasilClientSecret,
|
||||||
|
gs1BrasilTokenUrl: args.gs1BrasilTokenUrl || undefined,
|
||||||
|
gs1BrasilApiUrl: args.gs1BrasilApiUrl || undefined,
|
||||||
|
gs1BrasilAtivo: args.gs1BrasilAtivo,
|
||||||
|
bluesoftApiKey,
|
||||||
|
bluesoftApiUrl: args.bluesoftApiUrl || undefined,
|
||||||
|
bluesoftAtivo: args.bluesoftAtivo,
|
||||||
|
productSearchApiKey,
|
||||||
|
productSearchApiUrl: args.productSearchApiUrl || undefined,
|
||||||
|
productSearchAtivo: args.productSearchAtivo,
|
||||||
|
ativo: true,
|
||||||
|
configuradoPor: args.configuradoPorId,
|
||||||
|
atualizadoEm: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log de atividade
|
||||||
|
await registrarAtividade(
|
||||||
|
ctx,
|
||||||
|
args.configuradoPorId,
|
||||||
|
'configurar',
|
||||||
|
'buscaCodigoBarras',
|
||||||
|
JSON.stringify({
|
||||||
|
gs1BrasilAtivo: args.gs1BrasilAtivo,
|
||||||
|
bluesoftAtivo: args.bluesoftAtivo,
|
||||||
|
productSearchAtivo: args.productSearchAtivo
|
||||||
|
}),
|
||||||
|
configId
|
||||||
|
);
|
||||||
|
|
||||||
|
return { sucesso: true as const, configId };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obter configuração de busca de código de barras (internal query)
|
||||||
|
* Usado pela action de busca para obter credenciais descriptografadas
|
||||||
|
*/
|
||||||
|
export const obterConfigBuscaCodigoBarrasInternal = internalQuery({
|
||||||
|
args: {},
|
||||||
|
handler: async (ctx) => {
|
||||||
|
const config = await ctx.db
|
||||||
|
.query('configuracaoBuscaCodigoBarras')
|
||||||
|
.withIndex('by_ativo', (q) => q.eq('ativo', true))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retornar config completa (para uso interno)
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
@@ -242,5 +242,29 @@ export const systemTables = {
|
|||||||
.index('by_status_code', ['statusCode'])
|
.index('by_status_code', ['statusCode'])
|
||||||
.index('by_notificado', ['notificado'])
|
.index('by_notificado', ['notificado'])
|
||||||
.index('by_criado_em', ['criadoEm'])
|
.index('by_criado_em', ['criadoEm'])
|
||||||
.index('by_usuario', ['usuarioId'])
|
.index('by_usuario', ['usuarioId']),
|
||||||
|
|
||||||
|
// Configuração de APIs de Busca de Código de Barras
|
||||||
|
configuracaoBuscaCodigoBarras: defineTable({
|
||||||
|
// GS1 Brasil
|
||||||
|
gs1BrasilClientId: v.optional(v.string()),
|
||||||
|
gs1BrasilClientSecret: v.optional(v.string()),
|
||||||
|
gs1BrasilTokenUrl: v.optional(v.string()),
|
||||||
|
gs1BrasilApiUrl: v.optional(v.string()),
|
||||||
|
gs1BrasilAtivo: v.boolean(),
|
||||||
|
|
||||||
|
// Bluesoft Cosmo
|
||||||
|
bluesoftApiKey: v.optional(v.string()),
|
||||||
|
bluesoftApiUrl: v.optional(v.string()),
|
||||||
|
bluesoftAtivo: v.boolean(),
|
||||||
|
|
||||||
|
// Product-Search.net
|
||||||
|
productSearchApiKey: v.optional(v.string()),
|
||||||
|
productSearchApiUrl: v.optional(v.string()),
|
||||||
|
productSearchAtivo: v.boolean(),
|
||||||
|
|
||||||
|
ativo: v.boolean(),
|
||||||
|
configuradoPor: v.id('usuarios'),
|
||||||
|
atualizadoEm: v.number()
|
||||||
|
}).index('by_ativo', ['ativo'])
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user