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:
2025-12-21 09:07:03 -03:00
parent fdbecff4fa
commit e4ffc1ae2a
10 changed files with 1656 additions and 37 deletions

View File

@@ -53,6 +53,7 @@
"emoji-picker-element": "^1.27.0",
"eslint": "catalog:",
"exceljs": "^4.4.0",
"html5-qrcode": "^2.3.8",
"is-network-error": "^1.3.0",
"jspdf": "^3.0.3",
"jspdf-autotable": "^5.0.2",

View 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>

View 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>

View File

@@ -5,6 +5,7 @@
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { Package, Plus, Search, Edit, Eye, AlertTriangle } from 'lucide-svelte';
import BarcodeScanner from '$lib/components/almoxarifado/BarcodeScanner.svelte';
const client = useConvexClient();
@@ -14,6 +15,10 @@
let filtroCategoria = $state('');
let filtroAtivo = $state<boolean | ''>('');
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(
Array.from(new Set(materiais.map((m) => m.categoria).filter(Boolean))).sort()
@@ -25,7 +30,8 @@
const okBusca =
!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 okAtivo = filtroAtivo === '' || m.ativo === filtroAtivo;
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() {
const data = await client.query(api.almoxarifado.listarMateriais, {});
materiais = data ?? [];
@@ -50,6 +84,48 @@
function navCadastro() {
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>
<main class="container mx-auto px-4 py-6">
@@ -85,7 +161,14 @@
<!-- Filtros -->
<div class="card bg-base-100 border border-base-300 mb-6 shadow-xl">
<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="form-control">
<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" />
<input
type="text"
placeholder="Código ou nome..."
class="input input-bordered w-full pl-10"
placeholder="Código, nome ou código de barras..."
class="input input-bordered w-full pl-10 {buscandoPorCodigoBarras ? 'input-info' : ''}"
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>

View File

@@ -4,6 +4,8 @@
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
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();
@@ -17,8 +19,14 @@
let estoqueAtual = $state(0);
let localizacao = $state('');
let fornecedor = $state('');
let codigoBarras = $state('');
let imagemBase64 = $state<string | null>(null);
let scannerEnabled = $state(false);
let loading = $state(false);
let buscandoProduto = $state(false);
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 categoriasComuns = [
@@ -38,6 +46,94 @@
}, 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() {
// Validação
if (!codigo.trim() || !nome.trim() || !categoria.trim()) {
@@ -73,7 +169,9 @@
estoqueMaximo,
estoqueAtual,
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!');
@@ -135,6 +233,15 @@
<div class="card bg-base-100 border border-base-300 shadow-xl">
<div class="card-body">
<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">
<!-- Código -->
<div class="form-control md:col-span-1">
@@ -153,6 +260,31 @@
</label>
</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 -->
<div class="form-control md:col-span-1">
<label class="label">
@@ -283,6 +415,17 @@
bind:value={fornecedor}
/>
</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>
<!-- Botões -->

View File

@@ -2,6 +2,12 @@
import { api } from '@sgse-app/backend/convex/_generated/api';
import { useQuery } from 'convex-svelte';
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 {
BarChart3,
Package,
@@ -11,7 +17,9 @@
CheckCircle,
ArrowDown,
ArrowUp,
Settings
Settings,
FileText,
FileSpreadsheet
} from 'lucide-svelte';
const statsQuery = useQuery(api.almoxarifado.obterEstatisticas, {});
@@ -19,6 +27,9 @@
const movimentacoesQuery = useQuery(api.almoxarifado.listarMovimentacoes, {});
const alertasQuery = useQuery(api.almoxarifado.listarAlertas, { status: 'ativo' });
let gerandoRelatorio = $state(false);
let tipoRelatorioGerando = $state<string | null>(null);
// Agrupar materiais por categoria
const materiaisPorCategoria = $derived(() => {
if (!materiaisQuery.data) return {};
@@ -45,9 +56,693 @@
};
});
function exportarRelatorio(tipo: string) {
// Implementar exportação (CSV/Excel)
alert(`Exportação de ${tipo} será implementada em breve`);
// Função auxiliar para carregar logo
async function carregarLogo(): Promise<HTMLImageElement | null> {
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>
@@ -149,12 +844,34 @@
<div class="card-body">
<div class="flex items-center justify-between mb-4">
<h2 class="card-title">Materiais por Categoria</h2>
<button
class="btn btn-sm btn-ghost"
onclick={() => exportarRelatorio('materiais-categoria')}
>
<Download class="h-4 w-4" />
</button>
<div class="flex gap-2">
<button
class="btn btn-sm btn-primary"
onclick={gerarPDFMateriaisCategoria}
disabled={gerandoRelatorio}
title="Gerar PDF"
>
{#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
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>
{#if materiaisQuery.data && Object.keys(materiaisPorCategoria).length > 0}
<div class="space-y-2">
@@ -186,12 +903,34 @@
<div class="card-body">
<div class="flex items-center justify-between mb-4">
<h2 class="card-title">Movimentações do Mês</h2>
<button
class="btn btn-sm btn-ghost"
onclick={() => exportarRelatorio('movimentacoes-mes')}
>
<Download class="h-4 w-4" />
</button>
<div class="flex gap-2">
<button
class="btn btn-sm btn-primary"
onclick={gerarPDFMovimentacoesMes}
disabled={gerandoRelatorio}
title="Gerar PDF"
>
{#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
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 class="space-y-4">
<div class="flex items-center justify-between">
@@ -224,12 +963,34 @@
<div class="card-body">
<div class="flex items-center justify-between mb-4">
<h2 class="card-title">Materiais com Estoque Baixo</h2>
<button
class="btn btn-sm btn-ghost"
onclick={() => exportarRelatorio('estoque-baixo')}
>
<Download class="h-4 w-4" />
</button>
<div class="flex gap-2">
<button
class="btn btn-sm btn-primary"
onclick={gerarPDFEstoqueBaixo}
disabled={gerandoRelatorio}
title="Gerar PDF"
>
{#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
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>
{#if materiaisQuery.data}
{@const estoqueBaixo = materiaisQuery.data.filter(m => m.estoqueAtual <= m.estoqueMinimo)}
@@ -279,12 +1040,34 @@
<div class="card-body">
<div class="flex items-center justify-between mb-4">
<h2 class="card-title">Alertas Recentes</h2>
<button
class="btn btn-sm btn-ghost"
onclick={() => exportarRelatorio('alertas')}
>
<Download class="h-4 w-4" />
</button>
<div class="flex gap-2">
<button
class="btn btn-sm btn-primary"
onclick={gerarPDFAlertas}
disabled={gerandoRelatorio}
title="Gerar PDF"
>
{#if gerandoRelatorio && tipoRelatorioGerando === 'alertas-pdf'}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<FileText class="h-4 w-4" />
{/if}
PDF
</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>
{#if alertasQuery.data && alertasQuery.data.length > 0}
<div class="space-y-2">

View File

@@ -1,6 +1,5 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "sgse-app",
@@ -51,6 +50,7 @@
"emoji-picker-element": "^1.27.0",
"eslint": "catalog:",
"exceljs": "^4.4.0",
"html5-qrcode": "^2.3.8",
"is-network-error": "^1.3.0",
"jspdf": "^3.0.3",
"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=="],
"html5-qrcode": ["html5-qrcode@2.3.8", "", {}, "sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],

View 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;
}
}
});

View File

@@ -55,7 +55,8 @@ export const listarMateriais = query({
materiais = materiais.filter(
(m) =>
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({
args: {
materialId: v.optional(v.id('materiais')),
@@ -595,7 +620,10 @@ export const criarMaterial = mutation({
estoqueMaximo: v.optional(v.number()),
estoqueAtual: v.optional(v.number()),
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) => {
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
@@ -613,6 +641,18 @@ export const criarMaterial = mutation({
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);
if (!usuario) throw new Error('Usuário não autenticado');
@@ -650,6 +690,9 @@ export const editarMaterial = mutation({
estoqueMaximo: v.optional(v.number()),
localizacao: 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())
},
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 dadosNovos: Partial<Doc<'materiais'>> & { atualizadoEm: number } = {
atualizadoEm: Date.now()
@@ -688,6 +743,9 @@ export const editarMaterial = mutation({
if (args.estoqueMaximo !== undefined) dadosNovos.estoqueMaximo = args.estoqueMaximo;
if (args.localizacao !== undefined) dadosNovos.localizacao = args.localizacao;
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;
await ctx.db.patch(args.id, dadosNovos);

View File

@@ -44,6 +44,9 @@ export const almoxarifadoTables = {
estoqueAtual: v.number(),
localizacao: 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(),
criadoPor: v.id('usuarios'),
criadoEm: v.number(),
@@ -52,7 +55,8 @@ export const almoxarifadoTables = {
.index('by_codigo', ['codigo'])
.index('by_categoria', ['categoria'])
.index('by_ativo', ['ativo'])
.index('by_estoqueAtual', ['estoqueAtual']),
.index('by_estoqueAtual', ['estoqueAtual'])
.index('by_codigoBarras', ['codigoBarras']),
movimentacoesEstoque: defineTable({
materialId: v.id('materiais'),