diff --git a/apps/web/package.json b/apps/web/package.json index dc6ddb4..28fa6d5 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/src/lib/components/almoxarifado/BarcodeScanner.svelte b/apps/web/src/lib/components/almoxarifado/BarcodeScanner.svelte new file mode 100644 index 0000000..e9f97ea --- /dev/null +++ b/apps/web/src/lib/components/almoxarifado/BarcodeScanner.svelte @@ -0,0 +1,227 @@ + + +
+ {#if enabled} +
+
+
+

+ + Leitor de Código de Barras +

+ +
+ + {#if error} +
+ {error} +
+ {/if} + + {#if scanning} +
+
+
+

+ Posicione o código de barras dentro da área de leitura +

+

+ Ou use um leitor USB/Bluetooth para escanear +

+
+
+ {:else if !error} +
+ +

Iniciando scanner...

+
+ {/if} + +
+ +
+
+
+ {:else} + + {/if} +
+ + + diff --git a/apps/web/src/lib/components/almoxarifado/ImageUpload.svelte b/apps/web/src/lib/components/almoxarifado/ImageUpload.svelte new file mode 100644 index 0000000..cffbff0 --- /dev/null +++ b/apps/web/src/lib/components/almoxarifado/ImageUpload.svelte @@ -0,0 +1,197 @@ + + +
+ + + {#if preview} +
+ Preview da imagem do produto + +
+ {:else} +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + triggerFileInput(); + } + }} + > + +

Clique para fazer upload da imagem

+

+ PNG, JPG ou GIF até {maxSizeMB}MB +

+
+ {/if} + + {#if error} +
+ {error} +
+ {/if} + + {#if preview} + + {/if} +
+ + + diff --git a/apps/web/src/routes/(dashboard)/almoxarifado/materiais/+page.svelte b/apps/web/src/routes/(dashboard)/almoxarifado/materiais/+page.svelte index 82c4f85..187df92 100644 --- a/apps/web/src/routes/(dashboard)/almoxarifado/materiais/+page.svelte +++ b/apps/web/src/routes/(dashboard)/almoxarifado/materiais/+page.svelte @@ -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(''); let filtroEstoqueBaixo = $state(false); + let scannerEnabled = $state(false); + let materialEncontrado = $state | null>(null); + let buscandoPorCodigoBarras = $state(false); + let buscaTimeout: ReturnType | 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); + } + }; + });
@@ -85,7 +161,14 @@
-

Filtros de Busca

+
+

Filtros de Busca

+ console.error('Erro no scanner:', error)} + /> +
diff --git a/apps/web/src/routes/(dashboard)/almoxarifado/materiais/cadastro/+page.svelte b/apps/web/src/routes/(dashboard)/almoxarifado/materiais/cadastro/+page.svelte index 0957b45..7b6d1ed 100644 --- a/apps/web/src/routes/(dashboard)/almoxarifado/materiais/cadastro/+page.svelte +++ b/apps/web/src/routes/(dashboard)/almoxarifado/materiais/cadastro/+page.svelte @@ -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(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 | 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 @@
{ e.preventDefault(); handleSubmit(); }}> + +
+ mostrarMensagem('error', error)} + /> +
+
@@ -153,6 +260,31 @@
+ +
+ + + +
+
+ + +
+ + + +
diff --git a/apps/web/src/routes/(dashboard)/almoxarifado/relatorios/+page.svelte b/apps/web/src/routes/(dashboard)/almoxarifado/relatorios/+page.svelte index 400a23f..c10b490 100644 --- a/apps/web/src/routes/(dashboard)/almoxarifado/relatorios/+page.svelte +++ b/apps/web/src/routes/(dashboard)/almoxarifado/relatorios/+page.svelte @@ -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(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 { + try { + const logoImg = new Image(); + logoImg.crossOrigin = 'anonymous'; + logoImg.src = logoGovPE; + await new Promise((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 { + 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 { + 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((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; + } } @@ -149,12 +844,34 @@

Materiais por Categoria

- +
+ + +
{#if materiaisQuery.data && Object.keys(materiaisPorCategoria).length > 0}
@@ -186,12 +903,34 @@

Movimentações do Mês

- +
+ + +
@@ -224,12 +963,34 @@

Materiais com Estoque Baixo

- +
+ + +
{#if materiaisQuery.data} {@const estoqueBaixo = materiaisQuery.data.filter(m => m.estoqueAtual <= m.estoqueMinimo)} @@ -279,12 +1040,34 @@

Alertas Recentes

- +
+ + +
{#if alertasQuery.data && alertasQuery.data.length > 0}
diff --git a/bun.lock b/bun.lock index a4a37cf..645aa1c 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/packages/backend/convex/actions/buscarInfoProduto.ts b/packages/backend/convex/actions/buscarInfoProduto.ts new file mode 100644 index 0000000..aac8974 --- /dev/null +++ b/packages/backend/convex/actions/buscarInfoProduto.ts @@ -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 => { + 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; + } + } +}); + diff --git a/packages/backend/convex/almoxarifado.ts b/packages/backend/convex/almoxarifado.ts index fa083d5..7becb8a 100644 --- a/packages/backend/convex/almoxarifado.ts +++ b/packages/backend/convex/almoxarifado.ts @@ -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> & { 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); diff --git a/packages/backend/convex/tables/almoxarifado.ts b/packages/backend/convex/tables/almoxarifado.ts index bf16bcd..d4a05ed 100644 --- a/packages/backend/convex/tables/almoxarifado.ts +++ b/packages/backend/convex/tables/almoxarifado.ts @@ -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'),