diff --git a/README.md b/README.md new file mode 100644 index 0000000..85d27a0 --- /dev/null +++ b/README.md @@ -0,0 +1,100 @@ +# WMSS - Controle de Estoque por Área + +Aplicação web simples (HTML/CSS/JS) para operar com Supabase e gerenciar: + +- Cadastro/edição/exclusão de estoque por **área + SKU + tipo**. +- Cadastro de SKUs em `produtos`. +- Consulta de onde cada produto está e totais por SKU em todas as áreas. +- Expedição com baixa de estoque e registro em `movimentacoes`. +- Importação de planilha Excel/CSV para **incluir/atualizar/apagar** posições em lote. +- Exportação em planilha (XLSX) no fim de cada seção. + +## Como rodar o front + +Abra o `index.html` no navegador **ou** sirva com: + +```bash +python3 -m http.server 4173 +``` + +Depois acesse `http://localhost:4173`. + +## Importação de planilha (novo) + +Na aba **Cadastro** existe uma seção para upload de arquivo (`.xlsx`, `.xls`, `.csv`). + +### Colunas esperadas + +- `area` +- `sku` +- `tipo` +- `paletes` +- `acao` (opcional) + +### Regras + +- Se `acao` for `APAGAR`, `EXCLUIR`, `DELETE`, `DEL`, `REMOVER` ou `REMOVE`, a posição é apagada. +- Se `paletes = 0`, também apaga a posição. +- Nos demais casos, faz **incluir/atualizar** via `upsert`. + +### Exemplo + +| area | sku | tipo | paletes | acao | +|------|-----|------|---------|------| +| B02 | 20104409 | PL2 | 30 | | +| B02 | 30152626 | PBR | 25 | | +| B02 | 30152626 | PBR | 0 | APAGAR | + +## Novo SQL do sistema (Supabase) + +Foi adicionado o arquivo: + +- `supabase/schema_v2.sql` + +Esse script cria uma estrutura mais completa com: + +- tabela `areas` (cadastro de áreas), +- tabela `tipos_palete`, +- tabela `produtos`, +- tabela `estoque_area` com chaves estrangeiras, +- tabela `movimentacoes` com tipo de operação, +- função `fn_expedir_produto(...)` para baixa segura de estoque, +- views `vw_consulta_estoque` e `vw_totais_sku` para consultas, +- seeds iniciais de tipos (`PL2`, `PBR`, `FARDO`) e áreas exemplo. + +### Como aplicar no Supabase SQL Editor + +1. Abra o projeto no Supabase. +2. Entre em **SQL Editor**. +3. Cole o conteúdo de `supabase/schema_v2.sql`. +4. Execute o script. + +> Observação: o script é idempotente (usa `if not exists` e `on conflict do nothing`) para facilitar reexecução. + + +## Usando seu schema atual (somente ALTER TABLE) + +Como você já tem as tabelas criadas, adicionei um script só com alterações incrementais: + +- `supabase/alter_tables_from_current_schema.sql` + +Esse script **não recria tabelas**. Ele apenas: + +- adiciona checks básicos de qualidade de dados, +- cria índices de performance, +- adiciona colunas opcionais em `movimentacoes` (`operacao`, `area`, `observacao`), +- cria a view `vw_totais_sku`. + +Pode rodar direto no SQL Editor do Supabase. + + +## Layout visual do galpão + PDF + +Na aba **Cadastro**, clique em **"📦 Visualizar Layout (Cubículos)"** para abrir a planta: + +- colunas fixas: `TISSUE | C | B | A | LONIL`, +- leitura automática das áreas no padrão `TISSUE1`, `C1`, `B1`, `A1`, `LONIL1` etc, +- cores por tipo de material (`PL2`, `PBR`, `FARDO`), +- botão para exportar a planta em PDF. + +> Se a área não seguir esse padrão, ela continua aparecendo nas tabelas normais de estoque, mas não entra na planta fixa. diff --git a/app.js b/app.js new file mode 100644 index 0000000..d1ee46a --- /dev/null +++ b/app.js @@ -0,0 +1,499 @@ +const defaultConfig = { + url: 'https://qfjghplxbtogshfjkawx.supabase.co', + key: 'sb_publishable_rIcKdaflOvJ0DLTJDcOrxA_bpTGG2hA' +}; + +const el = { + supabaseUrl: document.getElementById('supabaseUrl'), + supabaseKey: document.getElementById('supabaseKey'), + connectBtn: document.getElementById('connectBtn'), + connectionStatus: document.getElementById('connectionStatus'), + feedback: document.getElementById('feedback'), + estoqueForm: document.getElementById('estoqueForm'), + produtoForm: document.getElementById('produtoForm'), + expedicaoForm: document.getElementById('expedicaoForm'), + importForm: document.getElementById('importForm'), + importFile: document.getElementById('importFile'), + estoqueTableBody: document.querySelector('#estoqueTable tbody'), + consultaAreaBody: document.querySelector('#consultaAreaTable tbody'), + totaisSkuBody: document.querySelector('#totaisSkuTable tbody'), + movimentacoesBody: document.querySelector('#movimentacoesTable tbody'), + exportCadastroBtn: document.getElementById('exportCadastroBtn'), + exportConsultaBtn: document.getElementById('exportConsultaBtn'), + exportExpedicaoBtn: document.getElementById('exportExpedicaoBtn'), + visualizarLayoutBtn: document.getElementById('visualizarLayoutBtn'), + layoutContainer: document.getElementById('layoutContainer'), + layoutGrid: document.getElementById('layoutGrid'), + exportLayoutPdfBtn: document.getElementById('exportLayoutPdfBtn'), + fecharLayoutBtn: document.getElementById('fecharLayoutBtn') +}; + +let supabaseClient; +let cache = { estoque: [], movimentacoes: [] }; + +el.supabaseUrl.value = defaultConfig.url; +el.supabaseKey.value = defaultConfig.key; + +function setStatus(target, message, type = '') { + target.textContent = message; + target.className = `status ${type}`.trim(); +} + +function showFeedback(message, type = 'success') { + setStatus(el.feedback, message, type); +} + +function normalizeText(value) { + return String(value ?? '').trim().toUpperCase(); +} + +function shouldDeleteByAction(actionValue) { + const action = normalizeText(actionValue); + return ['APAGAR', 'EXCLUIR', 'DELETE', 'DEL', 'REMOVER', 'REMOVE'].includes(action); +} + +function createClient() { + const url = el.supabaseUrl.value.trim(); + const key = el.supabaseKey.value.trim(); + if (!url || !key) { + setStatus(el.connectionStatus, 'Informe URL e chave para conectar.', 'error'); + return; + } + supabaseClient = window.supabase.createClient(url, key); + setStatus(el.connectionStatus, 'Conectado ao Supabase.', 'success'); + loadAll(); +} + +async function loadEstoque() { + const { data, error } = await supabaseClient + .from('estoque_area') + .select('area, sku, tipo, paletes') + .order('area', { ascending: true }) + .order('sku', { ascending: true }); + + if (error) throw error; + cache.estoque = data ?? []; + renderEstoque(); + renderConsulta(); +} + +async function loadMovimentacoes() { + const { data, error } = await supabaseClient + .from('movimentacoes') + .select('id, sku, tipo, paletes, created_at') + .order('created_at', { ascending: false }) + .limit(1000); + + if (error) throw error; + cache.movimentacoes = data ?? []; + renderMovimentacoes(); +} + +async function loadAll() { + if (!supabaseClient) return; + try { + await Promise.all([loadEstoque(), loadMovimentacoes()]); + showFeedback('Dados carregados com sucesso.'); + } catch (error) { + showFeedback(`Erro ao carregar dados: ${error.message}`, 'error'); + } +} + +function renderEstoque() { + el.estoqueTableBody.innerHTML = ''; + + cache.estoque.forEach((row) => { + const tr = document.createElement('tr'); + tr.innerHTML = ` + ${row.area} + ${row.sku} + ${row.tipo} + ${row.paletes} + + + + + `; + + tr.querySelector('[data-action="edit"]').addEventListener('click', () => { + el.estoqueForm.area.value = row.area; + el.estoqueForm.sku.value = row.sku; + el.estoqueForm.tipo.value = row.tipo; + el.estoqueForm.paletes.value = row.paletes; + showFeedback('Registro carregado no formulário para edição.'); + }); + + tr.querySelector('[data-action="delete"]').addEventListener('click', async () => { + if (!confirm(`Excluir ${row.sku} (${row.tipo}) da área ${row.area}?`)) return; + try { + const { error } = await supabaseClient + .from('estoque_area') + .delete() + .match({ area: row.area, sku: row.sku, tipo: row.tipo }); + + if (error) throw error; + showFeedback('Registro excluído com sucesso.'); + await loadEstoque(); + } catch (error) { + showFeedback(`Erro ao excluir: ${error.message}`, 'error'); + } + }); + + el.estoqueTableBody.appendChild(tr); + }); +} + +function groupTotalBySku(rows) { + const totals = rows.reduce((acc, row) => { + acc[row.sku] = (acc[row.sku] || 0) + Number(row.paletes); + return acc; + }, {}); + + return Object.entries(totals) + .map(([sku, total]) => ({ sku: Number(sku), total_paletes: total })) + .sort((a, b) => a.sku - b.sku); +} + +function renderConsulta() { + el.consultaAreaBody.innerHTML = ''; + cache.estoque.forEach((row) => { + const tr = document.createElement('tr'); + tr.innerHTML = `${row.area}${row.sku}${row.tipo}${row.paletes}`; + el.consultaAreaBody.appendChild(tr); + }); + + const totais = groupTotalBySku(cache.estoque); + el.totaisSkuBody.innerHTML = ''; + totais.forEach((item) => { + const tr = document.createElement('tr'); + tr.innerHTML = `${item.sku}${item.total_paletes}`; + el.totaisSkuBody.appendChild(tr); + }); +} + +function renderMovimentacoes() { + el.movimentacoesBody.innerHTML = ''; + cache.movimentacoes.forEach((row) => { + const tr = document.createElement('tr'); + tr.innerHTML = ` + ${new Date(row.created_at).toLocaleString('pt-BR')} + ${row.sku} + ${row.tipo} + ${row.paletes} + `; + el.movimentacoesBody.appendChild(tr); + }); +} + +async function handleEstoqueSubmit(event) { + event.preventDefault(); + if (!supabaseClient) return showFeedback('Conecte ao Supabase primeiro.', 'error'); + + const formData = new FormData(event.target); + const payload = { + area: normalizeText(formData.get('area')), + sku: Number(formData.get('sku')), + tipo: normalizeText(formData.get('tipo')), + paletes: Number(formData.get('paletes')) + }; + + try { + const { error } = await supabaseClient.from('estoque_area').upsert(payload); + if (error) throw error; + showFeedback('Estoque salvo com sucesso.'); + event.target.reset(); + await loadEstoque(); + } catch (error) { + showFeedback(`Erro ao salvar estoque: ${error.message}`, 'error'); + } +} + +async function handleProdutoSubmit(event) { + event.preventDefault(); + if (!supabaseClient) return showFeedback('Conecte ao Supabase primeiro.', 'error'); + + const formData = new FormData(event.target); + const payload = { + sku: Number(formData.get('sku')), + fardos_por_palete: Number(formData.get('fardos_por_palete')) + }; + + try { + const { error } = await supabaseClient.from('produtos').upsert(payload); + if (error) throw error; + showFeedback('Produto salvo com sucesso.'); + event.target.reset(); + } catch (error) { + showFeedback(`Erro ao salvar produto: ${error.message}`, 'error'); + } +} + +async function insertMovimentacaoExpedicao(area, sku, tipo, paletes) { + const payloadCompleto = { operacao: 'EXPEDICAO', area, sku, tipo, paletes }; + const tentativaCompleta = await supabaseClient.from('movimentacoes').insert(payloadCompleto); + + if (!tentativaCompleta.error) return; + + const payloadBasico = { sku, tipo, paletes }; + const tentativaBasica = await supabaseClient.from('movimentacoes').insert(payloadBasico); + if (tentativaBasica.error) throw tentativaCompleta.error; +} + +async function handleExpedicaoSubmit(event) { + event.preventDefault(); + if (!supabaseClient) return showFeedback('Conecte ao Supabase primeiro.', 'error'); + + const formData = new FormData(event.target); + const area = normalizeText(formData.get('area')); + const sku = Number(formData.get('sku')); + const tipo = normalizeText(formData.get('tipo')); + const paletes = Number(formData.get('paletes')); + + try { + const { data: atual, error: fetchError } = await supabaseClient + .from('estoque_area') + .select('paletes') + .match({ area, sku, tipo }) + .maybeSingle(); + + if (fetchError) throw fetchError; + if (!atual) throw new Error('Registro não encontrado no estoque.'); + if (atual.paletes < paletes) throw new Error('Quantidade para expedir maior que o estoque atual.'); + + const novoSaldo = atual.paletes - paletes; + + if (novoSaldo === 0) { + const { error: deleteError } = await supabaseClient + .from('estoque_area') + .delete() + .match({ area, sku, tipo }); + if (deleteError) throw deleteError; + } else { + const { error: updateError } = await supabaseClient + .from('estoque_area') + .update({ paletes: novoSaldo }) + .match({ area, sku, tipo }); + if (updateError) throw updateError; + } + + await insertMovimentacaoExpedicao(area, sku, tipo, paletes); + + showFeedback('Expedição registrada e estoque atualizado.'); + event.target.reset(); + await loadAll(); + } catch (error) { + showFeedback(`Erro na expedição: ${error.message}`, 'error'); + } +} + +function mapImportRow(rawRow) { + const row = Object.fromEntries( + Object.entries(rawRow).map(([k, v]) => [String(k).trim().toLowerCase(), v]) + ); + + return { + area: normalizeText(row.area), + sku: Number(row.sku), + tipo: normalizeText(row.tipo), + paletes: Number(row.paletes), + acao: normalizeText(row.acao) + }; +} + +function validateImportItem(item, line) { + if (!item.area) return `Linha ${line}: área inválida`; + if (!Number.isFinite(item.sku) || item.sku <= 0) return `Linha ${line}: sku inválido`; + if (!item.tipo) return `Linha ${line}: tipo inválido`; + if (!Number.isFinite(item.paletes) || item.paletes < 0) return `Linha ${line}: paletes inválido`; + return null; +} + +async function handleImportSubmit(event) { + event.preventDefault(); + if (!supabaseClient) return showFeedback('Conecte ao Supabase primeiro.', 'error'); + + const file = el.importFile.files?.[0]; + if (!file) return showFeedback('Selecione um arquivo para importar.', 'error'); + + try { + const buffer = await file.arrayBuffer(); + const workbook = XLSX.read(buffer, { type: 'array' }); + const sheet = workbook.Sheets[workbook.SheetNames[0]]; + const rows = XLSX.utils.sheet_to_json(sheet, { defval: '' }); + + if (!rows.length) throw new Error('Planilha vazia.'); + + let insertedOrUpdated = 0; + let deleted = 0; + + for (let i = 0; i < rows.length; i += 1) { + const item = mapImportRow(rows[i]); + const error = validateImportItem(item, i + 2); + if (error) throw new Error(error); + + if (shouldDeleteByAction(item.acao) || item.paletes === 0) { + const { error: deleteError } = await supabaseClient + .from('estoque_area') + .delete() + .match({ area: item.area, sku: item.sku, tipo: item.tipo }); + + if (deleteError) throw new Error(`Linha ${i + 2}: ${deleteError.message}`); + deleted += 1; + } else { + const payload = { + area: item.area, + sku: item.sku, + tipo: item.tipo, + paletes: item.paletes + }; + const { error: upsertError } = await supabaseClient.from('estoque_area').upsert(payload); + if (upsertError) throw new Error(`Linha ${i + 2}: ${upsertError.message}`); + insertedOrUpdated += 1; + } + } + + await loadEstoque(); + showFeedback(`Importação concluída. Incluídos/atualizados: ${insertedOrUpdated}. Apagados: ${deleted}.`); + el.importForm.reset(); + } catch (error) { + showFeedback(`Erro na importação: ${error.message}`, 'error'); + } +} + + +function parseAreaForLayout(areaRaw) { + const area = normalizeText(areaRaw); + const match = area.match(/^(TISSUE|LONIL|A|B|C)(\d+)$/); + if (!match) return null; + return { bloco: match[1], pos: Number(match[2]), area }; +} + +function gerarLayoutVisual() { + if (!el.layoutGrid || !el.layoutContainer) return; + + el.layoutGrid.innerHTML = ''; + const colunas = ['TISSUE', 'C', 'B', 'A', 'LONIL']; + + const parsed = cache.estoque + .map((item) => ({ item, meta: parseAreaForLayout(item.area) })) + .filter((entry) => entry.meta); + + const maxLinha = Math.max(12, ...parsed.map((entry) => entry.meta.pos)); + + for (let i = 1; i <= maxLinha; i += 1) { + colunas.forEach((coluna) => { + const cell = document.createElement('div'); + cell.className = 'celula vazio'; + const areaNome = `${coluna}${i}`; + + const itens = parsed + .filter((entry) => entry.meta.bloco === coluna && entry.meta.pos === i) + .map((entry) => entry.item); + + let conteudo = `${areaNome}`; + if (itens.length) { + cell.classList.remove('vazio'); + cell.classList.add('ocupado'); + + itens.forEach((item) => { + const tipoClass = `material-${normalizeText(item.tipo).toLowerCase()}`; + conteudo += `
SKU: ${item.sku}
${item.paletes} pal (${item.tipo})
`; + }); + } + + cell.innerHTML = conteudo; + el.layoutGrid.appendChild(cell); + }); + } + + el.layoutContainer.classList.remove('hidden'); +} + +async function exportarLayoutPDF() { + if (!window.html2canvas || !window.jspdf) { + showFeedback('Bibliotecas de PDF não carregadas.', 'error'); + return; + } + + try { + const canvas = await window.html2canvas(el.layoutGrid, { scale: 2 }); + const imgData = canvas.toDataURL('image/png'); + + const { jsPDF } = window.jspdf; + const pdf = new jsPDF('landscape'); + const pageWidth = pdf.internal.pageSize.getWidth(); + const pageHeight = pdf.internal.pageSize.getHeight(); + + const margin = 10; + const width = pageWidth - margin * 2; + const height = (canvas.height * width) / canvas.width; + + pdf.addImage(imgData, 'PNG', margin, margin, width, Math.min(height, pageHeight - margin * 2)); + pdf.save('Planta_Armazem.pdf'); + showFeedback('PDF do layout exportado com sucesso.'); + } catch (error) { + showFeedback(`Erro ao exportar PDF: ${error.message}`, 'error'); + } +} + +function exportWorkbook(fileName, sheets) { + const wb = XLSX.utils.book_new(); + sheets.forEach(({ name, data }) => { + const ws = XLSX.utils.json_to_sheet(data); + XLSX.utils.book_append_sheet(wb, ws, name); + }); + XLSX.writeFile(wb, fileName); +} + +function setupExports() { + el.exportCadastroBtn.addEventListener('click', () => { + const totais = groupTotalBySku(cache.estoque); + exportWorkbook('cadastro_estoque.xlsx', [ + { name: 'Estoque', data: cache.estoque }, + { name: 'Totais_SKU', data: totais } + ]); + }); + + el.exportConsultaBtn.addEventListener('click', () => { + const totais = groupTotalBySku(cache.estoque); + exportWorkbook('consulta_estoque.xlsx', [ + { name: 'Consulta_Areas', data: cache.estoque }, + { name: 'Totais_SKU', data: totais } + ]); + }); + + el.exportExpedicaoBtn.addEventListener('click', () => { + const totaisExpedido = groupTotalBySku(cache.movimentacoes); + exportWorkbook('expedicao.xlsx', [ + { name: 'Expedicoes', data: cache.movimentacoes }, + { name: 'Totais_Expedido_SKU', data: totaisExpedido } + ]); + }); +} + +function setupTabs() { + document.querySelectorAll('.tab-btn').forEach((btn) => { + btn.addEventListener('click', () => { + document.querySelectorAll('.tab-btn').forEach((b) => b.classList.remove('active')); + document.querySelectorAll('.tab-content').forEach((section) => section.classList.remove('active')); + btn.classList.add('active'); + document.getElementById(btn.dataset.tab).classList.add('active'); + }); + }); +} + +function init() { + setupTabs(); + setupExports(); + el.connectBtn.addEventListener('click', createClient); + el.estoqueForm.addEventListener('submit', handleEstoqueSubmit); + el.produtoForm.addEventListener('submit', handleProdutoSubmit); + el.expedicaoForm.addEventListener('submit', handleExpedicaoSubmit); + el.importForm.addEventListener('submit', handleImportSubmit); + el.visualizarLayoutBtn?.addEventListener('click', gerarLayoutVisual); + el.exportLayoutPdfBtn?.addEventListener('click', exportarLayoutPDF); + el.fecharLayoutBtn?.addEventListener('click', () => el.layoutContainer.classList.add('hidden')); + createClient(); +} + +init(); diff --git a/index.html b/index.html new file mode 100644 index 0000000..80cb433 --- /dev/null +++ b/index.html @@ -0,0 +1,175 @@ + + + + + + WMSS - Controle de Estoque + + + + + + + +
+

Controle de Estoque por Área

+

Cadastro, consulta e expedição de produtos

+
+ +
+
+

Configuração Supabase

+
+ + +
+ +

+
+ + + +
+
+

Cadastrar produto em área

+
+ + + + +
+ +
+
+
+ +
+

Cadastro de produto (SKU)

+
+ + +
+
+
+ +
+

Importar planilha (incluir / apagar posições)

+

+ Colunas esperadas na planilha: area, sku, tipo, + paletes e opcional acao. +
+ Se acao for APAGAR/EXCLUIR/DELETE, o registro será removido. + Caso contrário, será feito incluir/atualizar. +

+
+ +
+
+
+ +
+

Estoque por área

+
+ + + + + +
ÁreaSKUTipoPaletesAções
+
+
+ + +
+
+
+ +
+
+

Consulta: onde estão os produtos

+
+ + + +
ÁreaSKUTipoQuantidade
+
+
+ +
+

Totais por SKU (todas as áreas)

+
+ + + +
SKUTotal Paletes
+
+ +
+
+ +
+
+

Expedir (retirar do estoque)

+
+ + + + +
+
+
+ +
+

Histórico de expedição

+
+ + + +
DataSKUTipoQtd expedida
+
+ +
+
+ + + +

+
+ + + + diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..f54aa36 --- /dev/null +++ b/styles.css @@ -0,0 +1,185 @@ +:root { + font-family: Inter, Arial, sans-serif; + color: #182028; + background: #f5f7fa; +} + +* { box-sizing: border-box; } + +body { + margin: 0; +} + +header { + background: #1f4d8f; + color: #fff; + padding: 1.5rem; +} + +main { + max-width: 1100px; + margin: 1rem auto 2rem; + padding: 0 1rem; +} + +.card { + background: #fff; + border-radius: 10px; + padding: 1rem; + margin-bottom: 1rem; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); +} + +.grid { + display: grid; + gap: 0.75rem; +} + +.two-col { + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); +} + +.form-grid { + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + align-items: end; +} + +label { + display: flex; + flex-direction: column; + gap: 0.35rem; + font-size: 0.9rem; +} + +input, select, button { + padding: 0.55rem 0.7rem; + border-radius: 8px; + border: 1px solid #ccd4dd; + font-size: 0.95rem; +} + +button { + background: #1f4d8f; + color: #fff; + border: 0; + cursor: pointer; +} + +button.secondary { background: #51606f; } +button.danger { background: #b93636; } +button.edit-btn { background: #2a7f46; margin-right: 0.35rem; } +button.delete-btn { background: #b93636; } + +.actions { + display: flex; + align-items: flex-end; +} + +.tabs { + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.tab-btn { background: #8c96a3; } +.tab-btn.active { background: #1f4d8f; } + +.tab-content { display: none; } +.tab-content.active { display: block; } + +.table-wrapper { + overflow-x: auto; +} + +table { + width: 100%; + border-collapse: collapse; +} + +th, td { + border-bottom: 1px solid #e1e6eb; + padding: 0.55rem; + text-align: left; +} + +.status { + min-height: 1.3rem; + font-weight: 600; +} + +.status.error { color: #b93636; } +.status.success { color: #2a7f46; } + + +.helper-text { + margin-top: 0; + color: #4a5563; + font-size: 0.9rem; + line-height: 1.4; +} + +.layout-actions { + display: flex; + gap: 0.6rem; + margin-top: 0.8rem; + flex-wrap: wrap; +} + +.layout-btn { background: #2a7f46 !important; } + +.layout-container { + position: fixed; + inset: 0; + background: #f4f6f8; + overflow: auto; + padding: 1rem; + z-index: 1000; +} + +.layout-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.8rem; + background: #fff; + border-radius: 8px; + padding: 0.8rem; + margin-bottom: 1rem; +} + +.layout-header-actions { + display: flex; + gap: 0.5rem; +} + +.hidden { display: none !important; } + +.layout-grid { + display: grid; + grid-template-columns: 120px repeat(3, minmax(180px, 1fr)) 120px; + gap: 8px; +} + +.celula { + border: 2px solid #444; + min-height: 78px; + padding: 6px; + font-size: 12px; + background: #f5f5f5; +} + +.vazio { background: #e0e0e0; } +.ocupado { background: #90caf9; } + +.sku { + font-size: 11px; + margin-top: 4px; + border-radius: 4px; + padding: 4px; + color: #fff; + font-weight: 600; +} + +.material-pl2 { background: #1f4d8f; } +.material-pbr { background: #2a7f46; } +.material-fardo { background: #6c757d; } diff --git a/supabase/alter_tables_from_current_schema.sql b/supabase/alter_tables_from_current_schema.sql new file mode 100644 index 0000000..4c64125 --- /dev/null +++ b/supabase/alter_tables_from_current_schema.sql @@ -0,0 +1,69 @@ +-- ========================================================== +-- WMSS - ALTER TABLEs para schema atual informado pelo usuário +-- Base atual: +-- estoque_area(area, sku, paletes, tipo) +-- movimentacoes(id, sku, tipo, paletes, created_at) +-- produtos(sku, fardos_por_palete, tipo) +-- tipos_palete(tipo, fardos_por_palete) +-- ========================================================== + +-- 1) Regras mínimas de qualidade de dados +alter table if exists public.estoque_area + add constraint if not exists estoque_area_paletes_ck check (paletes >= 0); + +alter table if exists public.movimentacoes + add constraint if not exists movimentacoes_paletes_ck check (paletes > 0); + +alter table if exists public.produtos + add constraint if not exists produtos_fardos_ck check (fardos_por_palete > 0); + +-- 2) Índices úteis para consulta/performance +create index if not exists idx_estoque_area_sku on public.estoque_area (sku); +create index if not exists idx_estoque_area_area on public.estoque_area (area); +create index if not exists idx_movimentacoes_created_at on public.movimentacoes (created_at desc); +create index if not exists idx_movimentacoes_sku on public.movimentacoes (sku); + +-- 3) Colunas opcionais para registrar expedição com mais contexto +-- (o front já funciona sem elas; isso é apenas melhoria) +alter table if exists public.movimentacoes + add column if not exists operacao text; + +alter table if exists public.movimentacoes + add column if not exists area text; + +alter table if exists public.movimentacoes + add column if not exists observacao text; + +-- 4) Default e check da coluna operacao (somente se a coluna existir) +do $$ +begin + if exists ( + select 1 + from information_schema.columns + where table_schema = 'public' + and table_name = 'movimentacoes' + and column_name = 'operacao' + ) then + alter table public.movimentacoes + alter column operacao set default 'EXPEDICAO'; + + if not exists ( + select 1 + from pg_constraint + where conname = 'movimentacoes_operacao_ck' + ) then + alter table public.movimentacoes + add constraint movimentacoes_operacao_ck + check (operacao in ('ENTRADA', 'EXPEDICAO', 'AJUSTE')); + end if; + end if; +end $$; + +-- 5) View de totais por SKU para uso em consulta/relatórios +create or replace view public.vw_totais_sku as +select + sku, + sum(paletes)::integer as total_paletes +from public.estoque_area +group by sku +order by sku; diff --git a/supabase/schema_v2.sql b/supabase/schema_v2.sql new file mode 100644 index 0000000..c6cad51 --- /dev/null +++ b/supabase/schema_v2.sql @@ -0,0 +1,221 @@ +-- ========================================================== +-- WMSS - Schema SQL v2 (Supabase / PostgreSQL) +-- Objetivo: +-- - Cadastro de áreas e SKUs +-- - Controle de estoque por área + SKU + tipo +-- - Expedição com baixa de estoque e log de movimentações +-- - Consultas consolidadas (totais por área e por SKU) +-- ========================================================== + +-- Extensão para UUID aleatório +create extension if not exists pgcrypto; + +-- ----------------------------- +-- Tabelas de domínio +-- ----------------------------- +create table if not exists public.areas ( + codigo text primary key, + descricao text, + ativo boolean not null default true, + created_at timestamp without time zone not null default now(), + updated_at timestamp without time zone not null default now(), + constraint areas_codigo_ck check (codigo = upper(trim(codigo)) and length(trim(codigo)) > 0) +); + +create table if not exists public.tipos_palete ( + tipo text primary key, + fardos_por_palete integer, + created_at timestamp without time zone not null default now(), + constraint tipos_palete_tipo_ck check (tipo = upper(trim(tipo)) and length(trim(tipo)) > 0), + constraint tipos_palete_fardos_ck check (fardos_por_palete is null or fardos_por_palete > 0) +); + +create table if not exists public.produtos ( + sku bigint primary key, + descricao text, + fardos_por_palete integer not null, + created_at timestamp without time zone not null default now(), + updated_at timestamp without time zone not null default now(), + constraint produtos_sku_ck check (sku > 0), + constraint produtos_fardos_ck check (fardos_por_palete > 0) +); + +-- ----------------------------- +-- Estoque por área +-- ----------------------------- +create table if not exists public.estoque_area ( + area text not null, + sku bigint not null, + tipo text not null, + paletes integer not null, + created_at timestamp without time zone not null default now(), + updated_at timestamp without time zone not null default now(), + constraint estoque_area_pk primary key (area, sku, tipo), + constraint estoque_area_area_fk foreign key (area) references public.areas (codigo) on update cascade, + constraint estoque_area_sku_fk foreign key (sku) references public.produtos (sku) on update cascade, + constraint estoque_area_tipo_fk foreign key (tipo) references public.tipos_palete (tipo) on update cascade, + constraint estoque_area_paletes_ck check (paletes >= 0) +); + +create index if not exists idx_estoque_area_sku on public.estoque_area (sku); +create index if not exists idx_estoque_area_area on public.estoque_area (area); + +-- ----------------------------- +-- Movimentações +-- ----------------------------- +create table if not exists public.movimentacoes ( + id uuid primary key default gen_random_uuid(), + operacao text not null, + area text not null, + sku bigint not null, + tipo text not null, + paletes integer not null, + observacao text, + created_at timestamp without time zone not null default now(), + constraint movimentacoes_operacao_ck check (operacao in ('ENTRADA', 'EXPEDICAO', 'AJUSTE')), + constraint movimentacoes_paletes_ck check (paletes > 0), + constraint movimentacoes_area_fk foreign key (area) references public.areas (codigo) on update cascade, + constraint movimentacoes_sku_fk foreign key (sku) references public.produtos (sku) on update cascade, + constraint movimentacoes_tipo_fk foreign key (tipo) references public.tipos_palete (tipo) on update cascade +); + +create index if not exists idx_movimentacoes_data on public.movimentacoes (created_at desc); +create index if not exists idx_movimentacoes_sku on public.movimentacoes (sku); + +-- ----------------------------- +-- Trigger para updated_at +-- ----------------------------- +create or replace function public.fn_set_updated_at() +returns trigger +language plpgsql +as $$ +begin + new.updated_at = now(); + return new; +end; +$$; + +drop trigger if exists trg_areas_updated_at on public.areas; +create trigger trg_areas_updated_at +before update on public.areas +for each row execute function public.fn_set_updated_at(); + +drop trigger if exists trg_produtos_updated_at on public.produtos; +create trigger trg_produtos_updated_at +before update on public.produtos +for each row execute function public.fn_set_updated_at(); + +drop trigger if exists trg_estoque_area_updated_at on public.estoque_area; +create trigger trg_estoque_area_updated_at +before update on public.estoque_area +for each row execute function public.fn_set_updated_at(); + +-- ----------------------------- +-- Função de expedição (baixa segura) +-- ----------------------------- +create or replace function public.fn_expedir_produto( + p_area text, + p_sku bigint, + p_tipo text, + p_paletes integer, + p_observacao text default null +) +returns table ( + area text, + sku bigint, + tipo text, + saldo_paletes integer +) +language plpgsql +as $$ +declare + v_atual integer; + v_novo integer; +begin + if p_paletes is null or p_paletes <= 0 then + raise exception 'Quantidade para expedição deve ser maior que zero'; + end if; + + select e.paletes + into v_atual + from public.estoque_area e + where e.area = upper(trim(p_area)) + and e.sku = p_sku + and e.tipo = upper(trim(p_tipo)) + for update; + + if v_atual is null then + raise exception 'Registro não encontrado no estoque (área %, sku %, tipo %)', p_area, p_sku, p_tipo; + end if; + + if v_atual < p_paletes then + raise exception 'Estoque insuficiente para expedição. Atual: %, solicitado: %', v_atual, p_paletes; + end if; + + v_novo := v_atual - p_paletes; + + if v_novo = 0 then + delete from public.estoque_area + where area = upper(trim(p_area)) + and sku = p_sku + and tipo = upper(trim(p_tipo)); + else + update public.estoque_area + set paletes = v_novo + where area = upper(trim(p_area)) + and sku = p_sku + and tipo = upper(trim(p_tipo)); + end if; + + insert into public.movimentacoes (operacao, area, sku, tipo, paletes, observacao) + values ('EXPEDICAO', upper(trim(p_area)), p_sku, upper(trim(p_tipo)), p_paletes, p_observacao); + + return query + select upper(trim(p_area)), p_sku, upper(trim(p_tipo)), v_novo; +end; +$$; + +-- ----------------------------- +-- Views para consulta +-- ----------------------------- +create or replace view public.vw_consulta_estoque as +select + e.area, + e.sku, + p.descricao as produto, + e.tipo, + e.paletes, + coalesce(tp.fardos_por_palete, p.fardos_por_palete) as fardos_por_palete_ref, + e.paletes * coalesce(tp.fardos_por_palete, p.fardos_por_palete) as total_fardos_estimado +from public.estoque_area e +join public.produtos p on p.sku = e.sku +left join public.tipos_palete tp on tp.tipo = e.tipo; + +create or replace view public.vw_totais_sku as +select + e.sku, + p.descricao as produto, + sum(e.paletes)::integer as total_paletes, + sum(e.paletes * coalesce(tp.fardos_por_palete, p.fardos_por_palete))::integer as total_fardos_estimado +from public.estoque_area e +join public.produtos p on p.sku = e.sku +left join public.tipos_palete tp on tp.tipo = e.tipo +group by e.sku, p.descricao +order by e.sku; + +-- ----------------------------- +-- Seeds básicos (idempotentes) +-- ----------------------------- +insert into public.tipos_palete (tipo, fardos_por_palete) +values + ('PL2', null), + ('PBR', null), + ('FARDO', 1) +on conflict (tipo) do nothing; + +-- Exemplo de áreas +insert into public.areas (codigo, descricao) +values + ('B01', 'Área B01'), + ('B02', 'Área B02') +on conflict (codigo) do nothing;