feat: integrate barcode scanning functionality in 'Almoxarifado' for improved product search and registration, along with image upload support for enhanced inventory management
This commit is contained in:
@@ -53,6 +53,7 @@
|
|||||||
"emoji-picker-element": "^1.27.0",
|
"emoji-picker-element": "^1.27.0",
|
||||||
"eslint": "catalog:",
|
"eslint": "catalog:",
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
|
"html5-qrcode": "^2.3.8",
|
||||||
"is-network-error": "^1.3.0",
|
"is-network-error": "^1.3.0",
|
||||||
"jspdf": "^3.0.3",
|
"jspdf": "^3.0.3",
|
||||||
"jspdf-autotable": "^5.0.2",
|
"jspdf-autotable": "^5.0.2",
|
||||||
|
|||||||
227
apps/web/src/lib/components/almoxarifado/BarcodeScanner.svelte
Normal file
227
apps/web/src/lib/components/almoxarifado/BarcodeScanner.svelte
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import { Html5Qrcode, type Html5QrcodeResult } from 'html5-qrcode';
|
||||||
|
import { Camera, X, Scan } from 'lucide-svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onScan: (code: string) => void;
|
||||||
|
onError?: (error: string) => void;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { onScan, onError, enabled = $bindable(false) }: Props = $props();
|
||||||
|
|
||||||
|
let scanner: Html5Qrcode | null = $state(null);
|
||||||
|
let scanning = $state(false);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let scannerElement = $state<HTMLDivElement | null>(null);
|
||||||
|
let inputBuffer = $state('');
|
||||||
|
let inputTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const scannerId = `barcode-scanner-${Math.random().toString(36).substring(7)}`;
|
||||||
|
|
||||||
|
// Configuração do scanner
|
||||||
|
const config = {
|
||||||
|
fps: 10,
|
||||||
|
qrbox: { width: 250, height: 250 },
|
||||||
|
aspectRatio: 1.0
|
||||||
|
// A biblioteca html5-qrcode suporta automaticamente vários formatos:
|
||||||
|
// EAN-13, EAN-8, UPC-A, UPC-E, Code 128, Code 39, Code 93, QR Code, etc.
|
||||||
|
};
|
||||||
|
|
||||||
|
async function startScanning() {
|
||||||
|
if (!scannerElement) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
error = null;
|
||||||
|
scanning = true;
|
||||||
|
|
||||||
|
scanner = new Html5Qrcode(scannerId);
|
||||||
|
|
||||||
|
await scanner.start(
|
||||||
|
{ facingMode: 'environment' },
|
||||||
|
config,
|
||||||
|
(decodedText: string, decodedResult: Html5QrcodeResult) => {
|
||||||
|
handleScannedCode(decodedText);
|
||||||
|
},
|
||||||
|
(errorMessage: string) => {
|
||||||
|
// Ignorar erros de leitura contínua
|
||||||
|
if (errorMessage.includes('No MultiFormat Readers')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Erro ao iniciar scanner';
|
||||||
|
error = errorMessage;
|
||||||
|
scanning = false;
|
||||||
|
if (onError) {
|
||||||
|
onError(errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopScanning() {
|
||||||
|
if (scanner) {
|
||||||
|
try {
|
||||||
|
await scanner.stop();
|
||||||
|
await scanner.clear();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erro ao parar scanner:', err);
|
||||||
|
}
|
||||||
|
scanner = null;
|
||||||
|
}
|
||||||
|
scanning = false;
|
||||||
|
error = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScannedCode(code: string) {
|
||||||
|
if (code && code.trim()) {
|
||||||
|
stopScanning();
|
||||||
|
enabled = false;
|
||||||
|
onScan(code.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suporte para leitores USB/Bluetooth (captura de eventos de teclado)
|
||||||
|
function handleKeyPress(event: KeyboardEvent) {
|
||||||
|
// Ignorar se estiver digitando em um input
|
||||||
|
if (
|
||||||
|
event.target instanceof HTMLInputElement ||
|
||||||
|
event.target instanceof HTMLTextAreaElement ||
|
||||||
|
event.target instanceof HTMLSelectElement
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leitores de código de barras geralmente enviam caracteres rapidamente
|
||||||
|
if (event.key === 'Enter' && inputBuffer.trim()) {
|
||||||
|
event.preventDefault();
|
||||||
|
handleScannedCode(inputBuffer.trim());
|
||||||
|
inputBuffer = '';
|
||||||
|
if (inputTimeout) {
|
||||||
|
clearTimeout(inputTimeout);
|
||||||
|
inputTimeout = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Acumular caracteres digitados rapidamente
|
||||||
|
if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
||||||
|
inputBuffer += event.key;
|
||||||
|
if (inputTimeout) {
|
||||||
|
clearTimeout(inputTimeout);
|
||||||
|
}
|
||||||
|
inputTimeout = setTimeout(() => {
|
||||||
|
inputBuffer = '';
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleScanner() {
|
||||||
|
if (scanning) {
|
||||||
|
stopScanning();
|
||||||
|
enabled = false;
|
||||||
|
} else {
|
||||||
|
enabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (enabled && !scanning) {
|
||||||
|
startScanning();
|
||||||
|
} else if (!enabled && scanning) {
|
||||||
|
stopScanning();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
window.addEventListener('keydown', handleKeyPress);
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
window.removeEventListener('keydown', handleKeyPress);
|
||||||
|
if (inputTimeout) {
|
||||||
|
clearTimeout(inputTimeout);
|
||||||
|
}
|
||||||
|
stopScanning();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="barcode-scanner">
|
||||||
|
{#if enabled}
|
||||||
|
<div class="card bg-base-100 border border-base-300 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h3 class="text-lg font-semibold flex items-center gap-2">
|
||||||
|
<Scan class="h-5 w-5" />
|
||||||
|
Leitor de Código de Barras
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-ghost"
|
||||||
|
onclick={() => {
|
||||||
|
enabled = false;
|
||||||
|
}}
|
||||||
|
aria-label="Fechar scanner"
|
||||||
|
>
|
||||||
|
<X class="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="alert alert-error mb-4">
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if scanning}
|
||||||
|
<div class="relative">
|
||||||
|
<div id={scannerId} bind:this={scannerElement}></div>
|
||||||
|
<div class="mt-4 text-center">
|
||||||
|
<p class="text-sm text-base-content/70">
|
||||||
|
Posicione o código de barras dentro da área de leitura
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-base-content/50 mt-2">
|
||||||
|
Ou use um leitor USB/Bluetooth para escanear
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if !error}
|
||||||
|
<div class="text-center py-8">
|
||||||
|
<Camera class="h-16 w-16 mx-auto mb-4 text-base-content/30" />
|
||||||
|
<p class="text-base-content/70">Iniciando scanner...</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="card-actions justify-end mt-4">
|
||||||
|
<button type="button" class="btn btn-ghost" onclick={() => { enabled = false; }}>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-outline btn-primary"
|
||||||
|
onclick={toggleScanner}
|
||||||
|
aria-label="Abrir leitor de código de barras"
|
||||||
|
>
|
||||||
|
<Scan class="h-5 w-5" />
|
||||||
|
Ler Código de Barras
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.barcode-scanner {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
[id^='barcode-scanner-'] {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
197
apps/web/src/lib/components/almoxarifado/ImageUpload.svelte
Normal file
197
apps/web/src/lib/components/almoxarifado/ImageUpload.svelte
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Image, Upload, X } from 'lucide-svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value?: string | null;
|
||||||
|
onChange?: (base64: string | null) => void;
|
||||||
|
maxSizeMB?: number;
|
||||||
|
maxWidth?: number;
|
||||||
|
maxHeight?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
value = $bindable(null),
|
||||||
|
onChange,
|
||||||
|
maxSizeMB = 5,
|
||||||
|
maxWidth = 1200,
|
||||||
|
maxHeight = 1200
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let preview = $state<string | null>(value);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let inputElement: HTMLInputElement | null = null;
|
||||||
|
|
||||||
|
function handleFileSelect(event: Event) {
|
||||||
|
const target = event.target as HTMLInputElement;
|
||||||
|
const file = target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
error = null;
|
||||||
|
|
||||||
|
// Validar tamanho
|
||||||
|
if (file.size > maxSizeMB * 1024 * 1024) {
|
||||||
|
error = `Arquivo muito grande. Tamanho máximo: ${maxSizeMB}MB`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar tipo
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
error = 'Por favor, selecione um arquivo de imagem';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const result = e.target?.result as string;
|
||||||
|
if (result) {
|
||||||
|
// Redimensionar imagem se necessário
|
||||||
|
resizeImage(result, maxWidth, maxHeight)
|
||||||
|
.then((resized) => {
|
||||||
|
preview = resized;
|
||||||
|
value = resized;
|
||||||
|
if (onChange) {
|
||||||
|
onChange(resized);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
error = err instanceof Error ? err.message : 'Erro ao processar imagem';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.onerror = () => {
|
||||||
|
error = 'Erro ao ler arquivo';
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resizeImage(
|
||||||
|
dataUrl: string,
|
||||||
|
maxWidth: number,
|
||||||
|
maxHeight: number
|
||||||
|
): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
let width = img.width;
|
||||||
|
let height = img.height;
|
||||||
|
|
||||||
|
// Calcular novas dimensões mantendo proporção
|
||||||
|
if (width > maxWidth || height > maxHeight) {
|
||||||
|
const ratio = Math.min(maxWidth / width, maxHeight / height);
|
||||||
|
width = width * ratio;
|
||||||
|
height = height * ratio;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) {
|
||||||
|
reject(new Error('Não foi possível criar contexto do canvas'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.drawImage(img, 0, 0, width, height);
|
||||||
|
const resizedDataUrl = canvas.toDataURL('image/jpeg', 0.85);
|
||||||
|
resolve(resizedDataUrl);
|
||||||
|
};
|
||||||
|
|
||||||
|
img.onerror = () => {
|
||||||
|
reject(new Error('Erro ao carregar imagem'));
|
||||||
|
};
|
||||||
|
|
||||||
|
img.src = dataUrl;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeImage() {
|
||||||
|
preview = null;
|
||||||
|
value = null;
|
||||||
|
if (inputElement) {
|
||||||
|
inputElement.value = '';
|
||||||
|
}
|
||||||
|
if (onChange) {
|
||||||
|
onChange(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerFileInput() {
|
||||||
|
inputElement?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
preview = value;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="image-upload">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
class="hidden"
|
||||||
|
bind:this={inputElement}
|
||||||
|
onchange={handleFileSelect}
|
||||||
|
aria-label="Selecionar imagem do produto"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if preview}
|
||||||
|
<div class="relative inline-block">
|
||||||
|
<img src={preview} alt="Preview da imagem do produto" class="max-w-full max-h-64 rounded-lg" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-circle btn-error absolute top-2 right-2"
|
||||||
|
onclick={removeImage}
|
||||||
|
aria-label="Remover imagem"
|
||||||
|
>
|
||||||
|
<X class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="border-2 border-dashed border-base-300 rounded-lg p-8 text-center cursor-pointer hover:border-primary transition-colors"
|
||||||
|
onclick={triggerFileInput}
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
triggerFileInput();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Upload class="h-12 w-12 mx-auto mb-4 text-base-content/40" />
|
||||||
|
<p class="text-base-content/70 font-medium mb-2">Clique para fazer upload da imagem</p>
|
||||||
|
<p class="text-sm text-base-content/50">
|
||||||
|
PNG, JPG ou GIF até {maxSizeMB}MB
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="alert alert-error mt-4">
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if preview}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-outline btn-primary mt-4"
|
||||||
|
onclick={triggerFileInput}
|
||||||
|
>
|
||||||
|
<Image class="h-4 w-4" />
|
||||||
|
Alterar Imagem
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.image-upload {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import { Package, Plus, Search, Edit, Eye, AlertTriangle } from 'lucide-svelte';
|
import { Package, Plus, Search, Edit, Eye, AlertTriangle } from 'lucide-svelte';
|
||||||
|
import BarcodeScanner from '$lib/components/almoxarifado/BarcodeScanner.svelte';
|
||||||
|
|
||||||
const client = useConvexClient();
|
const client = useConvexClient();
|
||||||
|
|
||||||
@@ -14,6 +15,10 @@
|
|||||||
let filtroCategoria = $state('');
|
let filtroCategoria = $state('');
|
||||||
let filtroAtivo = $state<boolean | ''>('');
|
let filtroAtivo = $state<boolean | ''>('');
|
||||||
let filtroEstoqueBaixo = $state(false);
|
let filtroEstoqueBaixo = $state(false);
|
||||||
|
let scannerEnabled = $state(false);
|
||||||
|
let materialEncontrado = $state<Doc<'materiais'> | null>(null);
|
||||||
|
let buscandoPorCodigoBarras = $state(false);
|
||||||
|
let buscaTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
const categorias = $derived(
|
const categorias = $derived(
|
||||||
Array.from(new Set(materiais.map((m) => m.categoria).filter(Boolean))).sort()
|
Array.from(new Set(materiais.map((m) => m.categoria).filter(Boolean))).sort()
|
||||||
@@ -25,7 +30,8 @@
|
|||||||
const okBusca =
|
const okBusca =
|
||||||
!busca ||
|
!busca ||
|
||||||
m.codigo.toLowerCase().includes(busca) ||
|
m.codigo.toLowerCase().includes(busca) ||
|
||||||
m.nome.toLowerCase().includes(busca);
|
m.nome.toLowerCase().includes(busca) ||
|
||||||
|
(m.codigoBarras && m.codigoBarras.toLowerCase().includes(busca));
|
||||||
const okCategoria = !filtroCategoria || m.categoria === filtroCategoria;
|
const okCategoria = !filtroCategoria || m.categoria === filtroCategoria;
|
||||||
const okAtivo = filtroAtivo === '' || m.ativo === filtroAtivo;
|
const okAtivo = filtroAtivo === '' || m.ativo === filtroAtivo;
|
||||||
const okEstoqueBaixo = !filtroEstoqueBaixo || m.estoqueAtual <= m.estoqueMinimo;
|
const okEstoqueBaixo = !filtroEstoqueBaixo || m.estoqueAtual <= m.estoqueMinimo;
|
||||||
@@ -33,6 +39,34 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function buscarPorCodigoBarras(codigo: string) {
|
||||||
|
if (!codigo.trim() || codigo.trim().length < 8) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
buscandoPorCodigoBarras = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const material = await client.query(api.almoxarifado.buscarMaterialPorCodigoBarras, {
|
||||||
|
codigoBarras: codigo.trim()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (material) {
|
||||||
|
materialEncontrado = material;
|
||||||
|
// Filtrar para mostrar apenas o produto encontrado
|
||||||
|
filtroBusca = material.codigoBarras || material.codigo;
|
||||||
|
// Navegar para detalhes do produto após um pequeno delay
|
||||||
|
setTimeout(() => {
|
||||||
|
goto(resolve(`/almoxarifado/materiais/${material._id}`));
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erro ao buscar produto:', err);
|
||||||
|
} finally {
|
||||||
|
buscandoPorCodigoBarras = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
const data = await client.query(api.almoxarifado.listarMateriais, {});
|
const data = await client.query(api.almoxarifado.listarMateriais, {});
|
||||||
materiais = data ?? [];
|
materiais = data ?? [];
|
||||||
@@ -50,6 +84,48 @@
|
|||||||
function navCadastro() {
|
function navCadastro() {
|
||||||
goto(resolve('/almoxarifado/materiais/cadastro'));
|
goto(resolve('/almoxarifado/materiais/cadastro'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleBarcodeScanned(barcode: string) {
|
||||||
|
await buscarPorCodigoBarras(barcode);
|
||||||
|
if (!materialEncontrado) {
|
||||||
|
// Produto não encontrado, oferecer cadastro
|
||||||
|
if (confirm('Produto não encontrado. Deseja cadastrar um novo produto com este código de barras?')) {
|
||||||
|
goto(resolve('/almoxarifado/materiais/cadastro'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Busca automática quando código de barras é digitado no campo de busca
|
||||||
|
$effect(() => {
|
||||||
|
const busca = filtroBusca.trim();
|
||||||
|
|
||||||
|
// Limpar timeout anterior
|
||||||
|
if (buscaTimeout) {
|
||||||
|
clearTimeout(buscaTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se a busca foi limpa, resetar material encontrado
|
||||||
|
if (!busca) {
|
||||||
|
materialEncontrado = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se parece ser um código de barras (apenas números, 8-14 caracteres)
|
||||||
|
const pareceCodigoBarras = /^\d{8,14}$/.test(busca);
|
||||||
|
|
||||||
|
if (pareceCodigoBarras) {
|
||||||
|
// Aguardar 1 segundo após parar de digitar antes de buscar
|
||||||
|
buscaTimeout = setTimeout(() => {
|
||||||
|
buscarPorCodigoBarras(busca);
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (buscaTimeout) {
|
||||||
|
clearTimeout(buscaTimeout);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<main class="container mx-auto px-4 py-6">
|
<main class="container mx-auto px-4 py-6">
|
||||||
@@ -85,7 +161,14 @@
|
|||||||
<!-- Filtros -->
|
<!-- Filtros -->
|
||||||
<div class="card bg-base-100 border border-base-300 mb-6 shadow-xl">
|
<div class="card bg-base-100 border border-base-300 mb-6 shadow-xl">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h3 class="text-lg font-semibold mb-4">Filtros de Busca</h3>
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h3 class="text-lg font-semibold">Filtros de Busca</h3>
|
||||||
|
<BarcodeScanner
|
||||||
|
enabled={scannerEnabled}
|
||||||
|
onScan={handleBarcodeScanned}
|
||||||
|
onError={(error) => console.error('Erro no scanner:', error)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-4">
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
@@ -95,10 +178,13 @@
|
|||||||
<Search class="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-base-content/40" />
|
<Search class="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-base-content/40" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Código ou nome..."
|
placeholder="Código, nome ou código de barras..."
|
||||||
class="input input-bordered w-full pl-10"
|
class="input input-bordered w-full pl-10 {buscandoPorCodigoBarras ? 'input-info' : ''}"
|
||||||
bind:value={filtroBusca}
|
bind:value={filtroBusca}
|
||||||
/>
|
/>
|
||||||
|
{#if buscandoPorCodigoBarras}
|
||||||
|
<span class="loading loading-spinner loading-xs absolute right-3 top-1/2 -translate-y-1/2"></span>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
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 } from 'lucide-svelte';
|
||||||
|
import BarcodeScanner from '$lib/components/almoxarifado/BarcodeScanner.svelte';
|
||||||
|
import ImageUpload from '$lib/components/almoxarifado/ImageUpload.svelte';
|
||||||
|
|
||||||
const client = useConvexClient();
|
const client = useConvexClient();
|
||||||
|
|
||||||
@@ -17,8 +19,14 @@
|
|||||||
let estoqueAtual = $state(0);
|
let estoqueAtual = $state(0);
|
||||||
let localizacao = $state('');
|
let localizacao = $state('');
|
||||||
let fornecedor = $state('');
|
let fornecedor = $state('');
|
||||||
|
let codigoBarras = $state('');
|
||||||
|
let imagemBase64 = $state<string | null>(null);
|
||||||
|
let scannerEnabled = $state(false);
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
|
let buscandoProduto = $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('');
|
||||||
|
|
||||||
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 = [
|
||||||
@@ -38,6 +46,94 @@
|
|||||||
}, 5000);
|
}, 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function buscarProdutoPorCodigoBarras(barcode: string, mostrarMensagemSucesso = true) {
|
||||||
|
if (!barcode.trim() || barcode.trim().length < 8) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evitar busca duplicada do mesmo código
|
||||||
|
if (ultimoCodigoBuscado === barcode.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
buscandoProduto = true;
|
||||||
|
ultimoCodigoBuscado = barcode.trim();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Buscar produto existente pelo código de barras
|
||||||
|
const materialExistente = await client.query(api.almoxarifado.buscarMaterialPorCodigoBarras, {
|
||||||
|
codigoBarras: barcode.trim()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (materialExistente) {
|
||||||
|
// Preencher campos automaticamente
|
||||||
|
codigo = materialExistente.codigo;
|
||||||
|
nome = materialExistente.nome;
|
||||||
|
descricao = materialExistente.descricao || '';
|
||||||
|
categoria = materialExistente.categoria;
|
||||||
|
unidadeMedida = materialExistente.unidadeMedida;
|
||||||
|
estoqueMinimo = materialExistente.estoqueMinimo;
|
||||||
|
estoqueMaximo = materialExistente.estoqueMaximo;
|
||||||
|
estoqueAtual = materialExistente.estoqueAtual;
|
||||||
|
localizacao = materialExistente.localizacao || '';
|
||||||
|
fornecedor = materialExistente.fornecedor || '';
|
||||||
|
imagemBase64 = materialExistente.imagemBase64 || null;
|
||||||
|
|
||||||
|
if (mostrarMensagemSucesso) {
|
||||||
|
mostrarMensagem('success', 'Produto encontrado! Campos preenchidos automaticamente.');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Produto não encontrado
|
||||||
|
if (mostrarMensagemSucesso) {
|
||||||
|
mostrarMensagem('success', 'Código de barras lido. Complete as informações do produto.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Erro ao buscar produto';
|
||||||
|
if (mostrarMensagemSucesso) {
|
||||||
|
mostrarMensagem('error', message);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
buscandoProduto = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBarcodeScanned(barcode: string) {
|
||||||
|
codigoBarras = barcode;
|
||||||
|
await buscarProdutoPorCodigoBarras(barcode, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Busca automática quando código de barras é digitado manualmente
|
||||||
|
$effect(() => {
|
||||||
|
const codigo = codigoBarras.trim();
|
||||||
|
|
||||||
|
// Limpar timeout anterior
|
||||||
|
if (buscaTimeout) {
|
||||||
|
clearTimeout(buscaTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se o código foi limpo, resetar último código buscado
|
||||||
|
if (!codigo) {
|
||||||
|
ultimoCodigoBuscado = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aguardar 800ms após parar de digitar antes de buscar
|
||||||
|
// Isso evita muitas buscas enquanto o usuário está digitando
|
||||||
|
buscaTimeout = setTimeout(() => {
|
||||||
|
// Só buscar se o código tiver pelo menos 8 caracteres (tamanho mínimo de código de barras)
|
||||||
|
if (codigo.length >= 8) {
|
||||||
|
buscarProdutoPorCodigoBarras(codigo, false);
|
||||||
|
}
|
||||||
|
}, 800);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (buscaTimeout) {
|
||||||
|
clearTimeout(buscaTimeout);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
// Validação
|
// Validação
|
||||||
if (!codigo.trim() || !nome.trim() || !categoria.trim()) {
|
if (!codigo.trim() || !nome.trim() || !categoria.trim()) {
|
||||||
@@ -73,7 +169,9 @@
|
|||||||
estoqueMaximo,
|
estoqueMaximo,
|
||||||
estoqueAtual,
|
estoqueAtual,
|
||||||
localizacao: localizacao.trim() || undefined,
|
localizacao: localizacao.trim() || undefined,
|
||||||
fornecedor: fornecedor.trim() || undefined
|
fornecedor: fornecedor.trim() || undefined,
|
||||||
|
codigoBarras: codigoBarras.trim() || undefined,
|
||||||
|
imagemBase64: imagemBase64 || undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
mostrarMensagem('success', 'Material cadastrado com sucesso!');
|
mostrarMensagem('success', 'Material cadastrado com sucesso!');
|
||||||
@@ -135,6 +233,15 @@
|
|||||||
<div class="card bg-base-100 border border-base-300 shadow-xl">
|
<div class="card bg-base-100 border border-base-300 shadow-xl">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
|
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
|
||||||
|
<!-- Leitor de Código de Barras -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<BarcodeScanner
|
||||||
|
enabled={scannerEnabled}
|
||||||
|
onScan={handleBarcodeScanned}
|
||||||
|
onError={(error) => mostrarMensagem('error', error)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||||
<!-- Código -->
|
<!-- Código -->
|
||||||
<div class="form-control md:col-span-1">
|
<div class="form-control md:col-span-1">
|
||||||
@@ -153,6 +260,31 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Código de Barras -->
|
||||||
|
<div class="form-control md:col-span-1">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Código de Barras</span>
|
||||||
|
{#if buscandoProduto}
|
||||||
|
<span class="loading loading-spinner loading-xs"></span>
|
||||||
|
{/if}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered {buscandoProduto ? 'input-info' : ''}"
|
||||||
|
placeholder="EAN-13, UPC, etc."
|
||||||
|
bind:value={codigoBarras}
|
||||||
|
/>
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text-alt">
|
||||||
|
{#if buscandoProduto}
|
||||||
|
Buscando produto...
|
||||||
|
{:else}
|
||||||
|
Digite ou use o leitor acima para escanear
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Nome -->
|
<!-- Nome -->
|
||||||
<div class="form-control md:col-span-1">
|
<div class="form-control md:col-span-1">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
@@ -283,6 +415,17 @@
|
|||||||
bind:value={fornecedor}
|
bind:value={fornecedor}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Imagem do Produto -->
|
||||||
|
<div class="form-control md:col-span-2">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Imagem do Produto</span>
|
||||||
|
</label>
|
||||||
|
<ImageUpload bind:value={imagemBase64} />
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text-alt">Upload opcional da imagem do produto</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Botões -->
|
<!-- Botões -->
|
||||||
|
|||||||
@@ -2,6 +2,12 @@
|
|||||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||||
import { useQuery } from 'convex-svelte';
|
import { useQuery } from 'convex-svelte';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { ptBR } from 'date-fns/locale';
|
||||||
|
import jsPDF from 'jspdf';
|
||||||
|
import autoTable from 'jspdf-autotable';
|
||||||
|
import ExcelJS from 'exceljs';
|
||||||
|
import logoGovPE from '$lib/assets/logo_governo_PE.png';
|
||||||
import {
|
import {
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Package,
|
Package,
|
||||||
@@ -11,7 +17,9 @@
|
|||||||
CheckCircle,
|
CheckCircle,
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
Settings
|
Settings,
|
||||||
|
FileText,
|
||||||
|
FileSpreadsheet
|
||||||
} from 'lucide-svelte';
|
} from 'lucide-svelte';
|
||||||
|
|
||||||
const statsQuery = useQuery(api.almoxarifado.obterEstatisticas, {});
|
const statsQuery = useQuery(api.almoxarifado.obterEstatisticas, {});
|
||||||
@@ -19,6 +27,9 @@
|
|||||||
const movimentacoesQuery = useQuery(api.almoxarifado.listarMovimentacoes, {});
|
const movimentacoesQuery = useQuery(api.almoxarifado.listarMovimentacoes, {});
|
||||||
const alertasQuery = useQuery(api.almoxarifado.listarAlertas, { status: 'ativo' });
|
const alertasQuery = useQuery(api.almoxarifado.listarAlertas, { status: 'ativo' });
|
||||||
|
|
||||||
|
let gerandoRelatorio = $state(false);
|
||||||
|
let tipoRelatorioGerando = $state<string | null>(null);
|
||||||
|
|
||||||
// Agrupar materiais por categoria
|
// Agrupar materiais por categoria
|
||||||
const materiaisPorCategoria = $derived(() => {
|
const materiaisPorCategoria = $derived(() => {
|
||||||
if (!materiaisQuery.data) return {};
|
if (!materiaisQuery.data) return {};
|
||||||
@@ -45,9 +56,693 @@
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
function exportarRelatorio(tipo: string) {
|
// Função auxiliar para carregar logo
|
||||||
// Implementar exportação (CSV/Excel)
|
async function carregarLogo(): Promise<HTMLImageElement | null> {
|
||||||
alert(`Exportação de ${tipo} será implementada em breve`);
|
try {
|
||||||
|
const logoImg = new Image();
|
||||||
|
logoImg.crossOrigin = 'anonymous';
|
||||||
|
logoImg.src = logoGovPE;
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
logoImg.onload = () => resolve();
|
||||||
|
logoImg.onerror = () => reject();
|
||||||
|
setTimeout(() => reject(), 3000);
|
||||||
|
});
|
||||||
|
return logoImg;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Não foi possível carregar a logo:', err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Função auxiliar para adicionar logo ao PDF
|
||||||
|
async function adicionarLogoPDF(doc: jsPDF): Promise<number> {
|
||||||
|
try {
|
||||||
|
const logoImg = await carregarLogo();
|
||||||
|
if (logoImg) {
|
||||||
|
const logoWidth = 25;
|
||||||
|
const aspectRatio = logoImg.height / logoImg.width;
|
||||||
|
const logoHeight = logoWidth * aspectRatio;
|
||||||
|
doc.addImage(logoImg, 'PNG', 15, 10, logoWidth, logoHeight);
|
||||||
|
return 10 + logoHeight + 5;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Erro ao adicionar logo:', err);
|
||||||
|
}
|
||||||
|
return 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Função auxiliar para adicionar rodapé ao PDF
|
||||||
|
function adicionarRodapePDF(doc: jsPDF) {
|
||||||
|
const pageCount = doc.getNumberOfPages();
|
||||||
|
for (let i = 1; i <= pageCount; i++) {
|
||||||
|
doc.setPage(i);
|
||||||
|
doc.setFontSize(8);
|
||||||
|
doc.setTextColor(128, 128, 128);
|
||||||
|
doc.text(
|
||||||
|
`SGSE - Sistema de Gerenciamento de Secretaria | Página ${i} de ${pageCount}`,
|
||||||
|
doc.internal.pageSize.getWidth() / 2,
|
||||||
|
doc.internal.pageSize.getHeight() - 10,
|
||||||
|
{ align: 'center' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Função auxiliar para carregar logo para Excel
|
||||||
|
async function carregarLogoExcel(): Promise<ArrayBuffer | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(logoGovPE);
|
||||||
|
if (response.ok) return await response.arrayBuffer();
|
||||||
|
} catch {
|
||||||
|
// Fallback via canvas
|
||||||
|
try {
|
||||||
|
const logoImg = await carregarLogo();
|
||||||
|
if (logoImg) {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = logoImg.width;
|
||||||
|
canvas.height = logoImg.height;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (ctx) {
|
||||||
|
ctx.drawImage(logoImg, 0, 0);
|
||||||
|
const blob = await new Promise<Blob>((resolve, reject) => {
|
||||||
|
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('Falha ao converter'))), 'image/png');
|
||||||
|
});
|
||||||
|
return await blob.arrayBuffer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Função auxiliar para adicionar título e logo ao Excel
|
||||||
|
async function adicionarTituloExcel(
|
||||||
|
worksheet: ExcelJS.Worksheet,
|
||||||
|
titulo: string,
|
||||||
|
colunas: number,
|
||||||
|
workbook: ExcelJS.Workbook
|
||||||
|
) {
|
||||||
|
worksheet.getRow(1).height = 60;
|
||||||
|
|
||||||
|
// Mesclar células para logo (A1:B1)
|
||||||
|
if (colunas >= 3) {
|
||||||
|
worksheet.mergeCells(1, 1, 1, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const logoCell = worksheet.getCell(1, 1);
|
||||||
|
logoCell.alignment = { vertical: 'middle', horizontal: 'left' };
|
||||||
|
if (colunas >= 3) {
|
||||||
|
logoCell.border = { right: { style: 'thin', color: { argb: 'FFE0E0E0' } } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const logoBuffer = await carregarLogoExcel();
|
||||||
|
if (logoBuffer) {
|
||||||
|
const bytes = new Uint8Array(logoBuffer);
|
||||||
|
let binary = '';
|
||||||
|
for (let i = 0; i < bytes.byteLength; i++) {
|
||||||
|
binary += String.fromCharCode(bytes[i]);
|
||||||
|
}
|
||||||
|
const logoBase64 = btoa(binary);
|
||||||
|
const logoId = workbook.addImage({
|
||||||
|
base64: `data:image/png;base64,${logoBase64}`,
|
||||||
|
extension: 'png'
|
||||||
|
});
|
||||||
|
worksheet.addImage(logoId, {
|
||||||
|
tl: { col: 0, row: 0 },
|
||||||
|
ext: { width: 140, height: 55 }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Título
|
||||||
|
if (colunas >= 3) {
|
||||||
|
worksheet.mergeCells(1, 3, 1, colunas);
|
||||||
|
const titleCell = worksheet.getCell(1, 3);
|
||||||
|
titleCell.value = titulo;
|
||||||
|
titleCell.font = { bold: true, size: 18, color: { argb: 'FF2980B9' } };
|
||||||
|
titleCell.alignment = { vertical: 'middle', horizontal: 'center' };
|
||||||
|
} else {
|
||||||
|
const titleCell = worksheet.getCell(1, colunas);
|
||||||
|
titleCell.value = titulo;
|
||||||
|
titleCell.font = { bold: true, size: 18, color: { argb: 'FF2980B9' } };
|
||||||
|
titleCell.alignment = { vertical: 'middle', horizontal: 'center' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Função auxiliar para estilizar cabeçalho Excel
|
||||||
|
function estilizarCabecalhoExcel(row: ExcelJS.Row) {
|
||||||
|
row.height = 22;
|
||||||
|
row.eachCell((cell) => {
|
||||||
|
cell.font = { bold: true, size: 11, color: { argb: 'FFFFFFFF' } };
|
||||||
|
cell.fill = {
|
||||||
|
type: 'pattern',
|
||||||
|
pattern: 'solid',
|
||||||
|
fgColor: { argb: 'FF2980B9' }
|
||||||
|
};
|
||||||
|
cell.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true };
|
||||||
|
cell.border = {
|
||||||
|
top: { style: 'thin', color: { argb: 'FF000000' } },
|
||||||
|
bottom: { style: 'thin', color: { argb: 'FF000000' } },
|
||||||
|
left: { style: 'thin', color: { argb: 'FF000000' } },
|
||||||
|
right: { style: 'thin', color: { argb: 'FF000000' } }
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Função auxiliar para estilizar linhas Excel (zebra)
|
||||||
|
function estilizarLinhaExcel(row: ExcelJS.Row, isEven: boolean) {
|
||||||
|
row.eachCell((cell) => {
|
||||||
|
cell.font = { size: 10, color: { argb: 'FF000000' } };
|
||||||
|
cell.fill = {
|
||||||
|
type: 'pattern',
|
||||||
|
pattern: 'solid',
|
||||||
|
fgColor: { argb: isEven ? 'FFF8F9FA' : 'FFFFFFFF' }
|
||||||
|
};
|
||||||
|
cell.border = {
|
||||||
|
top: { style: 'thin', color: { argb: 'FFE0E0E0' } },
|
||||||
|
bottom: { style: 'thin', color: { argb: 'FFE0E0E0' } },
|
||||||
|
left: { style: 'thin', color: { argb: 'FFE0E0E0' } },
|
||||||
|
right: { style: 'thin', color: { argb: 'FFE0E0E0' } }
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== RELATÓRIO: MATERIAIS POR CATEGORIA ==========
|
||||||
|
|
||||||
|
async function gerarPDFMateriaisCategoria() {
|
||||||
|
if (gerandoRelatorio) return;
|
||||||
|
gerandoRelatorio = true;
|
||||||
|
tipoRelatorioGerando = 'materiais-categoria-pdf';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const doc = new jsPDF();
|
||||||
|
let yPos = await adicionarLogoPDF(doc);
|
||||||
|
|
||||||
|
// Título
|
||||||
|
doc.setFontSize(20);
|
||||||
|
doc.setTextColor(41, 128, 185);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text('RELATÓRIO DE MATERIAIS POR CATEGORIA', 105, yPos, { align: 'center' });
|
||||||
|
yPos += 10;
|
||||||
|
|
||||||
|
doc.setFontSize(10);
|
||||||
|
doc.setTextColor(0, 0, 0);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.text(
|
||||||
|
`Gerado em: ${format(new Date(), "dd 'de' MMMM 'de' yyyy 'às' HH:mm", { locale: ptBR })}`,
|
||||||
|
105,
|
||||||
|
yPos,
|
||||||
|
{ align: 'center' }
|
||||||
|
);
|
||||||
|
yPos += 15;
|
||||||
|
|
||||||
|
const dados = materiaisPorCategoria;
|
||||||
|
const dadosArray = Object.entries(dados).map(([categoria, quantidade]) => [categoria, String(quantidade)]);
|
||||||
|
|
||||||
|
if (dadosArray.length === 0) {
|
||||||
|
doc.text('Nenhum dado disponível', 14, yPos);
|
||||||
|
} else {
|
||||||
|
autoTable(doc, {
|
||||||
|
startY: yPos,
|
||||||
|
head: [['Categoria', 'Quantidade']],
|
||||||
|
body: dadosArray,
|
||||||
|
theme: 'striped',
|
||||||
|
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold', textColor: [255, 255, 255] },
|
||||||
|
styles: { fontSize: 10 }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
adicionarRodapePDF(doc);
|
||||||
|
doc.save(`relatorio-materiais-categoria-${format(new Date(), 'yyyy-MM-dd-HHmm')}.pdf`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao gerar PDF:', error);
|
||||||
|
alert('Erro ao gerar relatório PDF. Tente novamente.');
|
||||||
|
} finally {
|
||||||
|
gerandoRelatorio = false;
|
||||||
|
tipoRelatorioGerando = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function gerarExcelMateriaisCategoria() {
|
||||||
|
if (gerandoRelatorio) return;
|
||||||
|
gerandoRelatorio = true;
|
||||||
|
tipoRelatorioGerando = 'materiais-categoria-excel';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
const worksheet = workbook.addWorksheet('Materiais por Categoria');
|
||||||
|
worksheet.columns = [
|
||||||
|
{ header: 'Categoria', key: 'categoria', width: 40 },
|
||||||
|
{ header: 'Quantidade', key: 'quantidade', width: 15 }
|
||||||
|
];
|
||||||
|
|
||||||
|
await adicionarTituloExcel(worksheet, 'RELATÓRIO DE MATERIAIS POR CATEGORIA', 2, workbook);
|
||||||
|
|
||||||
|
const headerRow = worksheet.getRow(2);
|
||||||
|
headerRow.values = ['Categoria', 'Quantidade'];
|
||||||
|
estilizarCabecalhoExcel(headerRow);
|
||||||
|
|
||||||
|
const dados = materiaisPorCategoria;
|
||||||
|
Object.entries(dados).forEach(([categoria, quantidade], idx) => {
|
||||||
|
const row = worksheet.addRow({ categoria, quantidade });
|
||||||
|
estilizarLinhaExcel(row, idx % 2 === 1);
|
||||||
|
row.getCell(2).alignment = { vertical: 'middle', horizontal: 'center' };
|
||||||
|
});
|
||||||
|
|
||||||
|
worksheet.getColumn(2).numFmt = '#,##0';
|
||||||
|
worksheet.views = [{ state: 'frozen', ySplit: 2, topLeftCell: 'A3', activeCell: 'A3' }];
|
||||||
|
|
||||||
|
const buffer = await workbook.xlsx.writeBuffer();
|
||||||
|
const blob = new Blob([buffer], {
|
||||||
|
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||||
|
});
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `relatorio-materiais-categoria-${format(new Date(), 'yyyy-MM-dd-HHmm')}.xlsx`;
|
||||||
|
link.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao gerar Excel:', error);
|
||||||
|
alert('Erro ao gerar relatório Excel. Tente novamente.');
|
||||||
|
} finally {
|
||||||
|
gerandoRelatorio = false;
|
||||||
|
tipoRelatorioGerando = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== RELATÓRIO: MOVIMENTAÇÕES DO MÊS ==========
|
||||||
|
|
||||||
|
async function gerarPDFMovimentacoesMes() {
|
||||||
|
if (gerandoRelatorio) return;
|
||||||
|
gerandoRelatorio = true;
|
||||||
|
tipoRelatorioGerando = 'movimentacoes-mes-pdf';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const doc = new jsPDF();
|
||||||
|
let yPos = await adicionarLogoPDF(doc);
|
||||||
|
|
||||||
|
// Título
|
||||||
|
doc.setFontSize(20);
|
||||||
|
doc.setTextColor(41, 128, 185);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text('RELATÓRIO DE MOVIMENTAÇÕES DO MÊS', 105, yPos, { align: 'center' });
|
||||||
|
yPos += 10;
|
||||||
|
|
||||||
|
doc.setFontSize(10);
|
||||||
|
doc.setTextColor(0, 0, 0);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.text(
|
||||||
|
`Gerado em: ${format(new Date(), "dd 'de' MMMM 'de' yyyy 'às' HH:mm", { locale: ptBR })}`,
|
||||||
|
105,
|
||||||
|
yPos,
|
||||||
|
{ align: 'center' }
|
||||||
|
);
|
||||||
|
yPos += 5;
|
||||||
|
doc.text(
|
||||||
|
`Mês de referência: ${format(new Date(), "MMMM 'de' yyyy", { locale: ptBR })}`,
|
||||||
|
105,
|
||||||
|
yPos,
|
||||||
|
{ align: 'center' }
|
||||||
|
);
|
||||||
|
yPos += 15;
|
||||||
|
|
||||||
|
const dados = movimentacoesMes;
|
||||||
|
const dadosArray = [
|
||||||
|
['Entradas', String(dados.entrada)],
|
||||||
|
['Saídas', String(dados.saida)],
|
||||||
|
['Ajustes', String(dados.ajuste)]
|
||||||
|
];
|
||||||
|
|
||||||
|
autoTable(doc, {
|
||||||
|
startY: yPos,
|
||||||
|
head: [['Tipo de Movimentação', 'Quantidade']],
|
||||||
|
body: dadosArray,
|
||||||
|
theme: 'striped',
|
||||||
|
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold', textColor: [255, 255, 255] },
|
||||||
|
styles: { fontSize: 10 }
|
||||||
|
});
|
||||||
|
|
||||||
|
adicionarRodapePDF(doc);
|
||||||
|
doc.save(`relatorio-movimentacoes-mes-${format(new Date(), 'yyyy-MM-dd-HHmm')}.pdf`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao gerar PDF:', error);
|
||||||
|
alert('Erro ao gerar relatório PDF. Tente novamente.');
|
||||||
|
} finally {
|
||||||
|
gerandoRelatorio = false;
|
||||||
|
tipoRelatorioGerando = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function gerarExcelMovimentacoesMes() {
|
||||||
|
if (gerandoRelatorio) return;
|
||||||
|
gerandoRelatorio = true;
|
||||||
|
tipoRelatorioGerando = 'movimentacoes-mes-excel';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
const worksheet = workbook.addWorksheet('Movimentações do Mês');
|
||||||
|
worksheet.columns = [
|
||||||
|
{ header: 'Tipo de Movimentação', key: 'tipo', width: 30 },
|
||||||
|
{ header: 'Quantidade', key: 'quantidade', width: 15 }
|
||||||
|
];
|
||||||
|
|
||||||
|
await adicionarTituloExcel(worksheet, 'RELATÓRIO DE MOVIMENTAÇÕES DO MÊS', 2, workbook);
|
||||||
|
|
||||||
|
const headerRow = worksheet.getRow(2);
|
||||||
|
headerRow.values = ['Tipo de Movimentação', 'Quantidade'];
|
||||||
|
estilizarCabecalhoExcel(headerRow);
|
||||||
|
|
||||||
|
const dados = movimentacoesMes;
|
||||||
|
const tipos = [
|
||||||
|
{ tipo: 'Entradas', quantidade: dados.entrada },
|
||||||
|
{ tipo: 'Saídas', quantidade: dados.saida },
|
||||||
|
{ tipo: 'Ajustes', quantidade: dados.ajuste }
|
||||||
|
];
|
||||||
|
|
||||||
|
tipos.forEach((item, idx) => {
|
||||||
|
const row = worksheet.addRow(item);
|
||||||
|
estilizarLinhaExcel(row, idx % 2 === 1);
|
||||||
|
row.getCell(2).alignment = { vertical: 'middle', horizontal: 'center' };
|
||||||
|
});
|
||||||
|
|
||||||
|
worksheet.getColumn(2).numFmt = '#,##0';
|
||||||
|
worksheet.views = [{ state: 'frozen', ySplit: 2, topLeftCell: 'A3', activeCell: 'A3' }];
|
||||||
|
|
||||||
|
const buffer = await workbook.xlsx.writeBuffer();
|
||||||
|
const blob = new Blob([buffer], {
|
||||||
|
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||||
|
});
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `relatorio-movimentacoes-mes-${format(new Date(), 'yyyy-MM-dd-HHmm')}.xlsx`;
|
||||||
|
link.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao gerar Excel:', error);
|
||||||
|
alert('Erro ao gerar relatório Excel. Tente novamente.');
|
||||||
|
} finally {
|
||||||
|
gerandoRelatorio = false;
|
||||||
|
tipoRelatorioGerando = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== RELATÓRIO: ESTOQUE BAIXO ==========
|
||||||
|
|
||||||
|
async function gerarPDFEstoqueBaixo() {
|
||||||
|
if (gerandoRelatorio) return;
|
||||||
|
gerandoRelatorio = true;
|
||||||
|
tipoRelatorioGerando = 'estoque-baixo-pdf';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const doc = new jsPDF();
|
||||||
|
let yPos = await adicionarLogoPDF(doc);
|
||||||
|
|
||||||
|
// Título
|
||||||
|
doc.setFontSize(20);
|
||||||
|
doc.setTextColor(41, 128, 185);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text('RELATÓRIO DE MATERIAIS COM ESTOQUE BAIXO', 105, yPos, { align: 'center' });
|
||||||
|
yPos += 10;
|
||||||
|
|
||||||
|
doc.setFontSize(10);
|
||||||
|
doc.setTextColor(0, 0, 0);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.text(
|
||||||
|
`Gerado em: ${format(new Date(), "dd 'de' MMMM 'de' yyyy 'às' HH:mm", { locale: ptBR })}`,
|
||||||
|
105,
|
||||||
|
yPos,
|
||||||
|
{ align: 'center' }
|
||||||
|
);
|
||||||
|
yPos += 15;
|
||||||
|
|
||||||
|
const materiais = materiaisQuery.data || [];
|
||||||
|
const estoqueBaixo = materiais.filter((m) => m.estoqueAtual <= m.estoqueMinimo);
|
||||||
|
|
||||||
|
if (estoqueBaixo.length === 0) {
|
||||||
|
doc.text('Nenhum material com estoque baixo', 14, yPos);
|
||||||
|
} else {
|
||||||
|
const dadosArray = estoqueBaixo.map((m) => [
|
||||||
|
m.codigo,
|
||||||
|
m.nome,
|
||||||
|
String(m.estoqueAtual),
|
||||||
|
String(m.estoqueMinimo)
|
||||||
|
]);
|
||||||
|
|
||||||
|
autoTable(doc, {
|
||||||
|
startY: yPos,
|
||||||
|
head: [['Código', 'Material', 'Estoque Atual', 'Estoque Mínimo']],
|
||||||
|
body: dadosArray,
|
||||||
|
theme: 'striped',
|
||||||
|
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold', textColor: [255, 255, 255] },
|
||||||
|
styles: { fontSize: 9 },
|
||||||
|
columnStyles: {
|
||||||
|
0: { cellWidth: 30 },
|
||||||
|
1: { cellWidth: 80 },
|
||||||
|
2: { cellWidth: 30, halign: 'center' },
|
||||||
|
3: { cellWidth: 30, halign: 'center' }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
adicionarRodapePDF(doc);
|
||||||
|
doc.save(`relatorio-estoque-baixo-${format(new Date(), 'yyyy-MM-dd-HHmm')}.pdf`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao gerar PDF:', error);
|
||||||
|
alert('Erro ao gerar relatório PDF. Tente novamente.');
|
||||||
|
} finally {
|
||||||
|
gerandoRelatorio = false;
|
||||||
|
tipoRelatorioGerando = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function gerarExcelEstoqueBaixo() {
|
||||||
|
if (gerandoRelatorio) return;
|
||||||
|
gerandoRelatorio = true;
|
||||||
|
tipoRelatorioGerando = 'estoque-baixo-excel';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
const worksheet = workbook.addWorksheet('Estoque Baixo');
|
||||||
|
worksheet.columns = [
|
||||||
|
{ header: 'Código', key: 'codigo', width: 20 },
|
||||||
|
{ header: 'Material', key: 'nome', width: 50 },
|
||||||
|
{ header: 'Estoque Atual', key: 'estoqueAtual', width: 18 },
|
||||||
|
{ header: 'Estoque Mínimo', key: 'estoqueMinimo', width: 18 }
|
||||||
|
];
|
||||||
|
|
||||||
|
await adicionarTituloExcel(worksheet, 'RELATÓRIO DE MATERIAIS COM ESTOQUE BAIXO', 4, workbook);
|
||||||
|
|
||||||
|
const headerRow = worksheet.getRow(2);
|
||||||
|
headerRow.values = ['Código', 'Material', 'Estoque Atual', 'Estoque Mínimo'];
|
||||||
|
estilizarCabecalhoExcel(headerRow);
|
||||||
|
|
||||||
|
const materiais = materiaisQuery.data || [];
|
||||||
|
const estoqueBaixo = materiais.filter((m) => m.estoqueAtual <= m.estoqueMinimo);
|
||||||
|
|
||||||
|
estoqueBaixo.forEach((material, idx) => {
|
||||||
|
const row = worksheet.addRow({
|
||||||
|
codigo: material.codigo,
|
||||||
|
nome: material.nome,
|
||||||
|
estoqueAtual: material.estoqueAtual,
|
||||||
|
estoqueMinimo: material.estoqueMinimo
|
||||||
|
});
|
||||||
|
estilizarLinhaExcel(row, idx % 2 === 1);
|
||||||
|
row.getCell(3).alignment = { vertical: 'middle', horizontal: 'center' };
|
||||||
|
row.getCell(4).alignment = { vertical: 'middle', horizontal: 'center' };
|
||||||
|
// Destaque para estoque crítico
|
||||||
|
if (material.estoqueAtual < material.estoqueMinimo) {
|
||||||
|
row.getCell(3).fill = {
|
||||||
|
type: 'pattern',
|
||||||
|
pattern: 'solid',
|
||||||
|
fgColor: { argb: 'FFFFE0E0' }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
worksheet.getColumn(3).numFmt = '#,##0';
|
||||||
|
worksheet.getColumn(4).numFmt = '#,##0';
|
||||||
|
worksheet.views = [{ state: 'frozen', ySplit: 2, topLeftCell: 'A3', activeCell: 'A3' }];
|
||||||
|
worksheet.autoFilter = {
|
||||||
|
from: { row: 2, column: 1 },
|
||||||
|
to: { row: 2, column: 4 }
|
||||||
|
};
|
||||||
|
|
||||||
|
const buffer = await workbook.xlsx.writeBuffer();
|
||||||
|
const blob = new Blob([buffer], {
|
||||||
|
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||||
|
});
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `relatorio-estoque-baixo-${format(new Date(), 'yyyy-MM-dd-HHmm')}.xlsx`;
|
||||||
|
link.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao gerar Excel:', error);
|
||||||
|
alert('Erro ao gerar relatório Excel. Tente novamente.');
|
||||||
|
} finally {
|
||||||
|
gerandoRelatorio = false;
|
||||||
|
tipoRelatorioGerando = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== RELATÓRIO: ALERTAS ==========
|
||||||
|
|
||||||
|
async function gerarPDFAlertas() {
|
||||||
|
if (gerandoRelatorio) return;
|
||||||
|
gerandoRelatorio = true;
|
||||||
|
tipoRelatorioGerando = 'alertas-pdf';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const doc = new jsPDF();
|
||||||
|
let yPos = await adicionarLogoPDF(doc);
|
||||||
|
|
||||||
|
// Título
|
||||||
|
doc.setFontSize(20);
|
||||||
|
doc.setTextColor(41, 128, 185);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text('RELATÓRIO DE ALERTAS DE ESTOQUE', 105, yPos, { align: 'center' });
|
||||||
|
yPos += 10;
|
||||||
|
|
||||||
|
doc.setFontSize(10);
|
||||||
|
doc.setTextColor(0, 0, 0);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.text(
|
||||||
|
`Gerado em: ${format(new Date(), "dd 'de' MMMM 'de' yyyy 'às' HH:mm", { locale: ptBR })}`,
|
||||||
|
105,
|
||||||
|
yPos,
|
||||||
|
{ align: 'center' }
|
||||||
|
);
|
||||||
|
yPos += 15;
|
||||||
|
|
||||||
|
const alertas = alertasQuery.data || [];
|
||||||
|
const materiais = materiaisQuery.data || [];
|
||||||
|
|
||||||
|
if (alertas.length === 0) {
|
||||||
|
doc.text('Nenhum alerta ativo', 14, yPos);
|
||||||
|
} else {
|
||||||
|
const dadosArray = alertas.map((alerta) => {
|
||||||
|
const material = materiais.find((m) => m._id === alerta.materialId);
|
||||||
|
return [
|
||||||
|
material?.codigo || '-',
|
||||||
|
material?.nome || 'Material não encontrado',
|
||||||
|
String(alerta.quantidadeAtual),
|
||||||
|
String(alerta.quantidadeMinima),
|
||||||
|
alerta.tipo === 'estoque_zerado' ? 'Zerado' : 'Mínimo'
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
autoTable(doc, {
|
||||||
|
startY: yPos,
|
||||||
|
head: [['Código', 'Material', 'Quantidade Atual', 'Quantidade Mínima', 'Tipo']],
|
||||||
|
body: dadosArray,
|
||||||
|
theme: 'striped',
|
||||||
|
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold', textColor: [255, 255, 255] },
|
||||||
|
styles: { fontSize: 9 },
|
||||||
|
columnStyles: {
|
||||||
|
0: { cellWidth: 25 },
|
||||||
|
1: { cellWidth: 70 },
|
||||||
|
2: { cellWidth: 25, halign: 'center' },
|
||||||
|
3: { cellWidth: 25, halign: 'center' },
|
||||||
|
4: { cellWidth: 25, halign: 'center' }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
adicionarRodapePDF(doc);
|
||||||
|
doc.save(`relatorio-alertas-${format(new Date(), 'yyyy-MM-dd-HHmm')}.pdf`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao gerar PDF:', error);
|
||||||
|
alert('Erro ao gerar relatório PDF. Tente novamente.');
|
||||||
|
} finally {
|
||||||
|
gerandoRelatorio = false;
|
||||||
|
tipoRelatorioGerando = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function gerarExcelAlertas() {
|
||||||
|
if (gerandoRelatorio) return;
|
||||||
|
gerandoRelatorio = true;
|
||||||
|
tipoRelatorioGerando = 'alertas-excel';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
const worksheet = workbook.addWorksheet('Alertas');
|
||||||
|
worksheet.columns = [
|
||||||
|
{ header: 'Código', key: 'codigo', width: 20 },
|
||||||
|
{ header: 'Material', key: 'nome', width: 50 },
|
||||||
|
{ header: 'Quantidade Atual', key: 'quantidadeAtual', width: 20 },
|
||||||
|
{ header: 'Quantidade Mínima', key: 'quantidadeMinima', width: 20 },
|
||||||
|
{ header: 'Tipo', key: 'tipo', width: 15 }
|
||||||
|
];
|
||||||
|
|
||||||
|
await adicionarTituloExcel(worksheet, 'RELATÓRIO DE ALERTAS DE ESTOQUE', 5, workbook);
|
||||||
|
|
||||||
|
const headerRow = worksheet.getRow(2);
|
||||||
|
headerRow.values = ['Código', 'Material', 'Quantidade Atual', 'Quantidade Mínima', 'Tipo'];
|
||||||
|
estilizarCabecalhoExcel(headerRow);
|
||||||
|
|
||||||
|
const alertas = alertasQuery.data || [];
|
||||||
|
const materiais = materiaisQuery.data || [];
|
||||||
|
|
||||||
|
alertas.forEach((alerta, idx) => {
|
||||||
|
const material = materiais.find((m) => m._id === alerta.materialId);
|
||||||
|
const row = worksheet.addRow({
|
||||||
|
codigo: material?.codigo || '-',
|
||||||
|
nome: material?.nome || 'Material não encontrado',
|
||||||
|
quantidadeAtual: alerta.quantidadeAtual,
|
||||||
|
quantidadeMinima: alerta.quantidadeMinima,
|
||||||
|
tipo: alerta.tipo === 'estoque_zerado' ? 'Zerado' : 'Mínimo'
|
||||||
|
});
|
||||||
|
estilizarLinhaExcel(row, idx % 2 === 1);
|
||||||
|
row.getCell(3).alignment = { vertical: 'middle', horizontal: 'center' };
|
||||||
|
row.getCell(4).alignment = { vertical: 'middle', horizontal: 'center' };
|
||||||
|
row.getCell(5).alignment = { vertical: 'middle', horizontal: 'center' };
|
||||||
|
// Destaque para alertas críticos
|
||||||
|
if (alerta.tipo === 'estoque_zerado') {
|
||||||
|
row.getCell(5).fill = {
|
||||||
|
type: 'pattern',
|
||||||
|
pattern: 'solid',
|
||||||
|
fgColor: { argb: 'FFFFE0E0' }
|
||||||
|
};
|
||||||
|
row.getCell(5).font = { size: 10, color: { argb: 'FF721C24' }, bold: true };
|
||||||
|
} else {
|
||||||
|
row.getCell(5).fill = {
|
||||||
|
type: 'pattern',
|
||||||
|
pattern: 'solid',
|
||||||
|
fgColor: { argb: 'FFFFF3CD' }
|
||||||
|
};
|
||||||
|
row.getCell(5).font = { size: 10, color: { argb: 'FF856404' } };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
worksheet.getColumn(3).numFmt = '#,##0';
|
||||||
|
worksheet.getColumn(4).numFmt = '#,##0';
|
||||||
|
worksheet.views = [{ state: 'frozen', ySplit: 2, topLeftCell: 'A3', activeCell: 'A3' }];
|
||||||
|
worksheet.autoFilter = {
|
||||||
|
from: { row: 2, column: 1 },
|
||||||
|
to: { row: 2, column: 5 }
|
||||||
|
};
|
||||||
|
|
||||||
|
const buffer = await workbook.xlsx.writeBuffer();
|
||||||
|
const blob = new Blob([buffer], {
|
||||||
|
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||||
|
});
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `relatorio-alertas-${format(new Date(), 'yyyy-MM-dd-HHmm')}.xlsx`;
|
||||||
|
link.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao gerar Excel:', error);
|
||||||
|
alert('Erro ao gerar relatório Excel. Tente novamente.');
|
||||||
|
} finally {
|
||||||
|
gerandoRelatorio = false;
|
||||||
|
tipoRelatorioGerando = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -149,12 +844,34 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h2 class="card-title">Materiais por Categoria</h2>
|
<h2 class="card-title">Materiais por Categoria</h2>
|
||||||
|
<div class="flex gap-2">
|
||||||
<button
|
<button
|
||||||
class="btn btn-sm btn-ghost"
|
class="btn btn-sm btn-primary"
|
||||||
onclick={() => exportarRelatorio('materiais-categoria')}
|
onclick={gerarPDFMateriaisCategoria}
|
||||||
|
disabled={gerandoRelatorio}
|
||||||
|
title="Gerar PDF"
|
||||||
>
|
>
|
||||||
<Download class="h-4 w-4" />
|
{#if gerandoRelatorio && tipoRelatorioGerando === 'materiais-categoria-pdf'}
|
||||||
|
<span class="loading loading-spinner loading-xs"></span>
|
||||||
|
{:else}
|
||||||
|
<FileText class="h-4 w-4" />
|
||||||
|
{/if}
|
||||||
|
PDF
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-success"
|
||||||
|
onclick={gerarExcelMateriaisCategoria}
|
||||||
|
disabled={gerandoRelatorio}
|
||||||
|
title="Gerar Excel"
|
||||||
|
>
|
||||||
|
{#if gerandoRelatorio && tipoRelatorioGerando === 'materiais-categoria-excel'}
|
||||||
|
<span class="loading loading-spinner loading-xs"></span>
|
||||||
|
{:else}
|
||||||
|
<FileSpreadsheet class="h-4 w-4" />
|
||||||
|
{/if}
|
||||||
|
Excel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if materiaisQuery.data && Object.keys(materiaisPorCategoria).length > 0}
|
{#if materiaisQuery.data && Object.keys(materiaisPorCategoria).length > 0}
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
@@ -186,12 +903,34 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h2 class="card-title">Movimentações do Mês</h2>
|
<h2 class="card-title">Movimentações do Mês</h2>
|
||||||
|
<div class="flex gap-2">
|
||||||
<button
|
<button
|
||||||
class="btn btn-sm btn-ghost"
|
class="btn btn-sm btn-primary"
|
||||||
onclick={() => exportarRelatorio('movimentacoes-mes')}
|
onclick={gerarPDFMovimentacoesMes}
|
||||||
|
disabled={gerandoRelatorio}
|
||||||
|
title="Gerar PDF"
|
||||||
>
|
>
|
||||||
<Download class="h-4 w-4" />
|
{#if gerandoRelatorio && tipoRelatorioGerando === 'movimentacoes-mes-pdf'}
|
||||||
|
<span class="loading loading-spinner loading-xs"></span>
|
||||||
|
{:else}
|
||||||
|
<FileText class="h-4 w-4" />
|
||||||
|
{/if}
|
||||||
|
PDF
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-success"
|
||||||
|
onclick={gerarExcelMovimentacoesMes}
|
||||||
|
disabled={gerandoRelatorio}
|
||||||
|
title="Gerar Excel"
|
||||||
|
>
|
||||||
|
{#if gerandoRelatorio && tipoRelatorioGerando === 'movimentacoes-mes-excel'}
|
||||||
|
<span class="loading loading-spinner loading-xs"></span>
|
||||||
|
{:else}
|
||||||
|
<FileSpreadsheet class="h-4 w-4" />
|
||||||
|
{/if}
|
||||||
|
Excel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
@@ -224,12 +963,34 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h2 class="card-title">Materiais com Estoque Baixo</h2>
|
<h2 class="card-title">Materiais com Estoque Baixo</h2>
|
||||||
|
<div class="flex gap-2">
|
||||||
<button
|
<button
|
||||||
class="btn btn-sm btn-ghost"
|
class="btn btn-sm btn-primary"
|
||||||
onclick={() => exportarRelatorio('estoque-baixo')}
|
onclick={gerarPDFEstoqueBaixo}
|
||||||
|
disabled={gerandoRelatorio}
|
||||||
|
title="Gerar PDF"
|
||||||
>
|
>
|
||||||
<Download class="h-4 w-4" />
|
{#if gerandoRelatorio && tipoRelatorioGerando === 'estoque-baixo-pdf'}
|
||||||
|
<span class="loading loading-spinner loading-xs"></span>
|
||||||
|
{:else}
|
||||||
|
<FileText class="h-4 w-4" />
|
||||||
|
{/if}
|
||||||
|
PDF
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-success"
|
||||||
|
onclick={gerarExcelEstoqueBaixo}
|
||||||
|
disabled={gerandoRelatorio}
|
||||||
|
title="Gerar Excel"
|
||||||
|
>
|
||||||
|
{#if gerandoRelatorio && tipoRelatorioGerando === 'estoque-baixo-excel'}
|
||||||
|
<span class="loading loading-spinner loading-xs"></span>
|
||||||
|
{:else}
|
||||||
|
<FileSpreadsheet class="h-4 w-4" />
|
||||||
|
{/if}
|
||||||
|
Excel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if materiaisQuery.data}
|
{#if materiaisQuery.data}
|
||||||
{@const estoqueBaixo = materiaisQuery.data.filter(m => m.estoqueAtual <= m.estoqueMinimo)}
|
{@const estoqueBaixo = materiaisQuery.data.filter(m => m.estoqueAtual <= m.estoqueMinimo)}
|
||||||
@@ -279,12 +1040,34 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h2 class="card-title">Alertas Recentes</h2>
|
<h2 class="card-title">Alertas Recentes</h2>
|
||||||
|
<div class="flex gap-2">
|
||||||
<button
|
<button
|
||||||
class="btn btn-sm btn-ghost"
|
class="btn btn-sm btn-primary"
|
||||||
onclick={() => exportarRelatorio('alertas')}
|
onclick={gerarPDFAlertas}
|
||||||
|
disabled={gerandoRelatorio}
|
||||||
|
title="Gerar PDF"
|
||||||
>
|
>
|
||||||
<Download class="h-4 w-4" />
|
{#if gerandoRelatorio && tipoRelatorioGerando === 'alertas-pdf'}
|
||||||
|
<span class="loading loading-spinner loading-xs"></span>
|
||||||
|
{:else}
|
||||||
|
<FileText class="h-4 w-4" />
|
||||||
|
{/if}
|
||||||
|
PDF
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-success"
|
||||||
|
onclick={gerarExcelAlertas}
|
||||||
|
disabled={gerandoRelatorio}
|
||||||
|
title="Gerar Excel"
|
||||||
|
>
|
||||||
|
{#if gerandoRelatorio && tipoRelatorioGerando === 'alertas-excel'}
|
||||||
|
<span class="loading loading-spinner loading-xs"></span>
|
||||||
|
{:else}
|
||||||
|
<FileSpreadsheet class="h-4 w-4" />
|
||||||
|
{/if}
|
||||||
|
Excel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if alertasQuery.data && alertasQuery.data.length > 0}
|
{#if alertasQuery.data && alertasQuery.data.length > 0}
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
|
|||||||
4
bun.lock
4
bun.lock
@@ -1,6 +1,5 @@
|
|||||||
{
|
{
|
||||||
"lockfileVersion": 1,
|
"lockfileVersion": 1,
|
||||||
"configVersion": 0,
|
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sgse-app",
|
"name": "sgse-app",
|
||||||
@@ -51,6 +50,7 @@
|
|||||||
"emoji-picker-element": "^1.27.0",
|
"emoji-picker-element": "^1.27.0",
|
||||||
"eslint": "catalog:",
|
"eslint": "catalog:",
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
|
"html5-qrcode": "^2.3.8",
|
||||||
"is-network-error": "^1.3.0",
|
"is-network-error": "^1.3.0",
|
||||||
"jspdf": "^3.0.3",
|
"jspdf": "^3.0.3",
|
||||||
"jspdf-autotable": "^5.0.2",
|
"jspdf-autotable": "^5.0.2",
|
||||||
@@ -1189,6 +1189,8 @@
|
|||||||
|
|
||||||
"html2canvas": ["html2canvas@1.4.1", "", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA=="],
|
"html2canvas": ["html2canvas@1.4.1", "", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA=="],
|
||||||
|
|
||||||
|
"html5-qrcode": ["html5-qrcode@2.3.8", "", {}, "sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ=="],
|
||||||
|
|
||||||
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||||
|
|
||||||
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||||
|
|||||||
118
packages/backend/convex/actions/buscarInfoProduto.ts
Normal file
118
packages/backend/convex/actions/buscarInfoProduto.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { action } from '../_generated/server';
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
|
interface OpenFoodFactsProduct {
|
||||||
|
product?: {
|
||||||
|
product_name?: string;
|
||||||
|
product_name_pt?: string;
|
||||||
|
generic_name?: string;
|
||||||
|
generic_name_pt?: string;
|
||||||
|
categories?: string;
|
||||||
|
categories_tags?: string[];
|
||||||
|
image_url?: string;
|
||||||
|
image_front_url?: string;
|
||||||
|
image_front_small_url?: string;
|
||||||
|
brands?: string;
|
||||||
|
quantity?: string;
|
||||||
|
packaging?: string;
|
||||||
|
};
|
||||||
|
status?: number;
|
||||||
|
status_verbose?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProductInfo {
|
||||||
|
nome?: string;
|
||||||
|
descricao?: string;
|
||||||
|
categoria?: string;
|
||||||
|
imagemUrl?: string;
|
||||||
|
marca?: string;
|
||||||
|
quantidade?: string;
|
||||||
|
embalagem?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Busca informações de produto via API externa (Open Food Facts)
|
||||||
|
* Esta é uma funcionalidade opcional que pode ser usada para preencher
|
||||||
|
* automaticamente informações de produtos quando disponível.
|
||||||
|
*/
|
||||||
|
export const buscarInfoProdutoPorCodigoBarras = action({
|
||||||
|
args: {
|
||||||
|
codigoBarras: v.string()
|
||||||
|
},
|
||||||
|
handler: async (ctx, args): Promise<ProductInfo | null> => {
|
||||||
|
const { codigoBarras } = args;
|
||||||
|
|
||||||
|
// Validar formato básico de código de barras (EAN-13, UPC, etc.)
|
||||||
|
if (!codigoBarras || codigoBarras.length < 8 || codigoBarras.length > 14) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Tentar buscar na API Open Food Facts (gratuita, sem autenticação)
|
||||||
|
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) // Timeout de 5 segundos
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
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 (primeira categoria disponível)
|
||||||
|
let categoria: string | undefined;
|
||||||
|
if (product.categories_tags && product.categories_tags.length > 0) {
|
||||||
|
// Pegar a primeira categoria e limpar tags
|
||||||
|
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
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
@@ -55,7 +55,8 @@ export const listarMateriais = query({
|
|||||||
materiais = materiais.filter(
|
materiais = materiais.filter(
|
||||||
(m) =>
|
(m) =>
|
||||||
m.codigo.toLowerCase().includes(buscaLower) ||
|
m.codigo.toLowerCase().includes(buscaLower) ||
|
||||||
m.nome.toLowerCase().includes(buscaLower)
|
m.nome.toLowerCase().includes(buscaLower) ||
|
||||||
|
(m.codigoBarras && m.codigoBarras.toLowerCase().includes(buscaLower))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,6 +82,30 @@ export const obterMaterial = query({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const buscarMaterialPorCodigoBarras = query({
|
||||||
|
args: { codigoBarras: v.string() },
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const usuario = await getCurrentUserFunction(ctx);
|
||||||
|
if (!usuario) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
|
||||||
|
recurso: 'almoxarifado',
|
||||||
|
acao: 'listar'
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const material = await ctx.db
|
||||||
|
.query('materiais')
|
||||||
|
.withIndex('by_codigoBarras', (q) => q.eq('codigoBarras', args.codigoBarras))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
return material ?? null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export const listarMovimentacoes = query({
|
export const listarMovimentacoes = query({
|
||||||
args: {
|
args: {
|
||||||
materialId: v.optional(v.id('materiais')),
|
materialId: v.optional(v.id('materiais')),
|
||||||
@@ -595,7 +620,10 @@ export const criarMaterial = mutation({
|
|||||||
estoqueMaximo: v.optional(v.number()),
|
estoqueMaximo: v.optional(v.number()),
|
||||||
estoqueAtual: v.optional(v.number()),
|
estoqueAtual: v.optional(v.number()),
|
||||||
localizacao: v.optional(v.string()),
|
localizacao: v.optional(v.string()),
|
||||||
fornecedor: v.optional(v.string())
|
fornecedor: v.optional(v.string()),
|
||||||
|
codigoBarras: v.optional(v.string()),
|
||||||
|
imagemUrl: v.optional(v.string()),
|
||||||
|
imagemBase64: v.optional(v.string())
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
|
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
|
||||||
@@ -613,6 +641,18 @@ export const criarMaterial = mutation({
|
|||||||
throw new Error('Código do material já existe');
|
throw new Error('Código do material já existe');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verificar se código de barras já existe (se fornecido)
|
||||||
|
if (args.codigoBarras) {
|
||||||
|
const codigoBarrasExistente = await ctx.db
|
||||||
|
.query('materiais')
|
||||||
|
.withIndex('by_codigoBarras', (q) => q.eq('codigoBarras', args.codigoBarras))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (codigoBarrasExistente) {
|
||||||
|
throw new Error('Código de barras já está cadastrado para outro material');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const usuario = await getCurrentUserFunction(ctx);
|
const usuario = await getCurrentUserFunction(ctx);
|
||||||
if (!usuario) throw new Error('Usuário não autenticado');
|
if (!usuario) throw new Error('Usuário não autenticado');
|
||||||
|
|
||||||
@@ -650,6 +690,9 @@ export const editarMaterial = mutation({
|
|||||||
estoqueMaximo: v.optional(v.number()),
|
estoqueMaximo: v.optional(v.number()),
|
||||||
localizacao: v.optional(v.string()),
|
localizacao: v.optional(v.string()),
|
||||||
fornecedor: v.optional(v.string()),
|
fornecedor: v.optional(v.string()),
|
||||||
|
codigoBarras: v.optional(v.string()),
|
||||||
|
imagemUrl: v.optional(v.string()),
|
||||||
|
imagemBase64: v.optional(v.string()),
|
||||||
ativo: v.optional(v.boolean())
|
ativo: v.optional(v.boolean())
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
@@ -673,6 +716,18 @@ export const editarMaterial = mutation({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verificar se código de barras já existe (se foi alterado)
|
||||||
|
if (args.codigoBarras && args.codigoBarras !== material.codigoBarras) {
|
||||||
|
const codigoBarrasExistente = await ctx.db
|
||||||
|
.query('materiais')
|
||||||
|
.withIndex('by_codigoBarras', (q) => q.eq('codigoBarras', args.codigoBarras))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (codigoBarrasExistente) {
|
||||||
|
throw new Error('Código de barras já está cadastrado para outro material');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const dadosAnteriores = { ...material };
|
const dadosAnteriores = { ...material };
|
||||||
const dadosNovos: Partial<Doc<'materiais'>> & { atualizadoEm: number } = {
|
const dadosNovos: Partial<Doc<'materiais'>> & { atualizadoEm: number } = {
|
||||||
atualizadoEm: Date.now()
|
atualizadoEm: Date.now()
|
||||||
@@ -688,6 +743,9 @@ export const editarMaterial = mutation({
|
|||||||
if (args.estoqueMaximo !== undefined) dadosNovos.estoqueMaximo = args.estoqueMaximo;
|
if (args.estoqueMaximo !== undefined) dadosNovos.estoqueMaximo = args.estoqueMaximo;
|
||||||
if (args.localizacao !== undefined) dadosNovos.localizacao = args.localizacao;
|
if (args.localizacao !== undefined) dadosNovos.localizacao = args.localizacao;
|
||||||
if (args.fornecedor !== undefined) dadosNovos.fornecedor = args.fornecedor;
|
if (args.fornecedor !== undefined) dadosNovos.fornecedor = args.fornecedor;
|
||||||
|
if (args.codigoBarras !== undefined) dadosNovos.codigoBarras = args.codigoBarras;
|
||||||
|
if (args.imagemUrl !== undefined) dadosNovos.imagemUrl = args.imagemUrl;
|
||||||
|
if (args.imagemBase64 !== undefined) dadosNovos.imagemBase64 = args.imagemBase64;
|
||||||
if (args.ativo !== undefined) dadosNovos.ativo = args.ativo;
|
if (args.ativo !== undefined) dadosNovos.ativo = args.ativo;
|
||||||
|
|
||||||
await ctx.db.patch(args.id, dadosNovos);
|
await ctx.db.patch(args.id, dadosNovos);
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ export const almoxarifadoTables = {
|
|||||||
estoqueAtual: v.number(),
|
estoqueAtual: v.number(),
|
||||||
localizacao: v.optional(v.string()),
|
localizacao: v.optional(v.string()),
|
||||||
fornecedor: v.optional(v.string()),
|
fornecedor: v.optional(v.string()),
|
||||||
|
codigoBarras: v.optional(v.string()),
|
||||||
|
imagemUrl: v.optional(v.string()),
|
||||||
|
imagemBase64: v.optional(v.string()),
|
||||||
ativo: v.boolean(),
|
ativo: v.boolean(),
|
||||||
criadoPor: v.id('usuarios'),
|
criadoPor: v.id('usuarios'),
|
||||||
criadoEm: v.number(),
|
criadoEm: v.number(),
|
||||||
@@ -52,7 +55,8 @@ export const almoxarifadoTables = {
|
|||||||
.index('by_codigo', ['codigo'])
|
.index('by_codigo', ['codigo'])
|
||||||
.index('by_categoria', ['categoria'])
|
.index('by_categoria', ['categoria'])
|
||||||
.index('by_ativo', ['ativo'])
|
.index('by_ativo', ['ativo'])
|
||||||
.index('by_estoqueAtual', ['estoqueAtual']),
|
.index('by_estoqueAtual', ['estoqueAtual'])
|
||||||
|
.index('by_codigoBarras', ['codigoBarras']),
|
||||||
|
|
||||||
movimentacoesEstoque: defineTable({
|
movimentacoesEstoque: defineTable({
|
||||||
materialId: v.id('materiais'),
|
materialId: v.id('materiais'),
|
||||||
|
|||||||
Reference in New Issue
Block a user