diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3295fb7 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +PORT=3000 +TERABOX_CLIENT_ID=SEU_CLIENT_ID +TERABOX_CLIENT_SECRET=SEU_CLIENT_SECRET +TERABOX_REDIRECT_URI=http://localhost:3000/auth/terabox/callback +TERABOX_TARGET_DIR=/Apps/RecebimentoJSL diff --git a/README.md b/README.md new file mode 100644 index 0000000..a59d260 --- /dev/null +++ b/README.md @@ -0,0 +1,104 @@ +# Sistema de Recebimento e Conferência (ADM x Operação) + +Aplicação web (HTML/CSS/JS) com Supabase para operação de recebimento de NF-e. + +## Principais pontos implementados + +- Login/cadastro separados por telas (matrícula + senha). +- Perfis `adm` e `operacao` com visibilidade por permissão. +- Upload XML no ADM com extração automática de motorista, telefone, placa e DT/remessa. +- Conferência da operação com marcação direta de **avaria** e **faltando**. +- NQ somente no ADM, retornando linha pronta para Excel: + `Data | Placa | Remessa | NF | CD de Origem | SKU | Qtde NF | Qtd Rec. FISICO`. +- Cargas conferidas em abas no ADM, com botão para **fechar descarga**. +- Logs em página separada com abas (Todos, Divergências, Falta, Normais), busca e botões de baixar/apagar. +- Destaque visual: divergência em vermelho, faltas em amarelo. + +## 1) Erro "Could not find the table 'public.usuarios'" + +Esse erro significa que o schema do banco ainda não está aplicado no projeto Supabase. + +### Correção definitiva +1. Abrir o **SQL Editor** do Supabase. +2. Executar o arquivo `supabase-schema.sql` (versão idempotente atualizada). +3. Recarregar o app. + +> O app entra em **MODO LOCAL** automaticamente quando não encontra as tabelas, para não travar testes. + +## 2) Configuração Supabase + +1. Crie um projeto no Supabase. +2. Rode `supabase-schema.sql` no SQL Editor. +3. Ajuste RLS/policies conforme sua segurança. +4. (Opcional) configure funções para recuperação de senha: + - `send-sms` (Twilio); + - `send-email` (Resend/SMTP). + +## 3) Executar localmente + +```bash +python3 -m http.server 8080 +``` + +Depois abra `http://localhost:8080`. + +## 3.1) Backend Node para TeraBox (upload de TXT) + +1. Copie o arquivo de ambiente: + ```bash + cp .env.example .env + ``` +2. Preencha no `.env`: + - `TERABOX_CLIENT_ID` + - `TERABOX_CLIENT_SECRET` + - `TERABOX_REDIRECT_URI` + - `TERABOX_TARGET_DIR` +3. Instale dependências e suba o backend: + ```bash + npm install + npm start + ``` +4. Faça autenticação no TeraBox: + - abra `http://localhost:3000/auth/terabox` +5. No painel ADM, página **Logs**, clique em **Guardar** para enviar o TXT de logs ao TeraBox. + +### Usando somente Render (sem localhost) +Se front e backend estiverem em serviços Render/Netlify, não use `localhost`. + +1. Backend no Render: copie a URL pública, ex.: `https://recebimento-api.onrender.com`. +2. Abra o front uma vez com: + `https://seu-front.onrender.com/?backend_url=https://recebimento-api.onrender.com` +3. O sistema salva essa URL em `localStorage` (`rcv-backend-url`) e o botão **Guardar** passa a usar o backend remoto. + +> Fluxo implementado no backend: OAuth (authorization code) → pre-upload → upload da parte → create file. +> Nome do arquivo: `logs-carregamento-YYYYMMDD-HHMMSS.txt`. + +## 4) Fluxo por perfil + +### ADM +- Publica XML para conferência; +- acompanha cargas por abas (abertas/conferidas/NQ); +- fecha descarga; +- acessa página exclusiva de logs com busca, exportação e limpeza; +- copia linhas NQ para Excel. + +### Operação +- Seleciona nota publicada; +- informa quantidades conferidas; +- marca se veio avariado e se veio faltando; +- envia conferência para retorno do ADM. + +## 5) SMS e E-mail + +Se SMS/e-mail não funcionar, normalmente faltam as edge functions no projeto Supabase. + +Exemplo: +```bash +supabase functions deploy send-email +supabase functions deploy send-sms +supabase secrets set CHAVE=valor +``` + +## 6) Observação de segurança + +Este MVP mantém senha em texto puro para simplicidade operacional. Para produção, usar Supabase Auth + hash de senha. diff --git a/index.html b/index.html new file mode 100644 index 0000000..c5ceebf --- /dev/null +++ b/index.html @@ -0,0 +1,167 @@ + + + + + + Recebimento e Conferência + + + + + + + +
+
+ Logo JSL +

Recebimento de Notas

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

Acesso

+
+ + +
+ +
+

Entrar

+ + + +
+ Recuperar senha + + +
+

Para SMS/e-mail no Supabase, configure as edge functions send-sms e send-email.

+
+ + +
+ + + + + + +
+ + + + + + + diff --git a/main.js b/main.js new file mode 100644 index 0000000..a824e10 --- /dev/null +++ b/main.js @@ -0,0 +1,974 @@ +const SUPABASE_URL = 'https://qkdonbbvafdbooyjjmwb.supabase.co'; +const SUPABASE_PUBLISHABLE_KEY = 'sb_publishable_JYiZBz-B3k7pdY3Ivobn0w_Jz7zIWNx'; +const supabase = window.supabase.createClient(SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY); +const BACKEND_BASE_URL = (() => { + const params = new URLSearchParams(window.location.search); + const fromQuery = params.get('backend_url'); + if (fromQuery) localStorage.setItem('rcv-backend-url', fromQuery); + return localStorage.getItem('rcv-backend-url') || window.location.origin; +})(); + +const state = { + user: null, + currentInvoice: null, + invoices: [], + logs: [], + nqLines: [], + mode: 'supabase', + authView: 'login', + adminTab: 'abertas', + logFilter: 'all', + recebimentoMap: JSON.parse(localStorage.getItem('rcv-recebimento-map') || '{}'), +}; + +const localDb = { + read: () => JSON.parse(localStorage.getItem('rcv-local-db') || '{"usuarios":[],"notas":[],"conferencias":[],"logs":[],"chats":[],"nq_reports":[]}'), + write: (data) => localStorage.setItem('rcv-local-db', JSON.stringify(data)), + nextId: (items) => (items.at(-1)?.id || 0) + 1, +}; + +const qs = (id) => document.getElementById(id); +const authSection = qs('authSection'); +const adminSection = qs('adminSection'); +const operacaoSection = qs('operacaoSection'); +const logsSection = qs('logsSection'); +const registerForm = qs('registerForm'); +const loginForm = qs('loginForm'); +const uploadXmlForm = qs('uploadXmlForm'); +const conferenciaForm = qs('conferenciaForm'); +const notaSelect = qs('notaSelect'); +const invoicePreview = qs('invoicePreview'); +const logsContainer = qs('logsContainer'); +const adminConferencias = qs('adminConferencias'); +const adminNqContainer = qs('adminNqContainer'); +const adminDescargasContainer = qs('adminDescargasContainer'); +const adminRecebimentoContainer = qs('adminRecebimentoContainer'); +const notaInfo = qs('notaInfo'); +const duplicateWarning = qs('duplicateWarning'); +const operacaoStats = qs('operacaoStats'); + +const FARDO_POR_PALETE = { + 20081464: 36, 20081465: 36, 20081466: 36, 20081467: 36, 20081469: 28, 20081481: 28, 20081482: 36, 20081579: 30, 20091834: 36, 20091836: 27, + 20104309: 45, 20104310: 225, 20104313: 36, 20104405: 48, 20104407: 32, 20104408: 36, 20104409: 36, 20104410: 36, 20104411: 12, 20104412: 28, + 20104413: 36, 20104414: 36, 20104415: 36, 20104416: 36, 20104417: 32, 20104418: 28, 20104419: 33, 20104420: 63, 20104421: 63, + 20104422: 65, 20104425: 36, 20104426: 27, 20104427: 14, 20104429: 27, 20104430: 15, 20105277: 27, 20106704: 36, 20106705: 36, + 20108498: 24, 20109727: 24, 20109735: 36, 20109736: 36, 20110078: 36, 90004854: 30, +}; + +const isSchemaMissingError = (error) => { + const msg = error?.message || ''; + return msg.includes('schema cache') || msg.includes('Could not find the table') || msg.includes('relation') || msg.includes('does not exist'); +}; + +async function maybeSwitchToLocalMode(error) { + if (!error || !isSchemaMissingError(error) || state.mode === 'local') return false; + state.mode = 'local'; + alert('Sem schema do Supabase. Entrando em MODO LOCAL. Rode o arquivo supabase-schema.sql atualizado no SQL Editor para ativar banco online.'); + return true; +} + +async function dbSelect(table, options = {}) { + if (state.mode === 'local') { + let rows = [...(localDb.read()[table] || [])]; + Object.entries(options.eq || {}).forEach(([k, v]) => { rows = rows.filter((r) => String(r[k]) === String(v)); }); + if (options.orderBy) rows.sort((a, b) => new Date(b[options.orderBy] || 0) - new Date(a[options.orderBy] || 0)); + return options.single ? rows[0] || null : rows; + } + let query = supabase.from(table).select('*'); + Object.entries(options.eq || {}).forEach(([k, v]) => { query = query.eq(k, v); }); + if (options.orderBy) query = query.order(options.orderBy, { ascending: false }); + if (options.single) query = query.single(); + const { data, error } = await query; + if (await maybeSwitchToLocalMode(error)) return dbSelect(table, options); + if (error) throw error; + return data; +} + +async function dbInsert(table, payload) { + if (state.mode === 'local') { + const db = localDb.read(); + const arr = db[table] || []; + const item = { ...payload, id: localDb.nextId(arr), created_at: new Date().toISOString() }; + arr.push(item); + db[table] = arr; + localDb.write(db); + return item; + } + const { data, error } = await supabase.from(table).insert(payload).select('*').single(); + if (await maybeSwitchToLocalMode(error)) return dbInsert(table, payload); + if (error) throw error; + return data; +} + +async function dbUpdate(table, match, patch) { + if (state.mode === 'local') { + const db = localDb.read(); + db[table] = (db[table] || []).map((r) => Object.entries(match).every(([k, v]) => String(r[k]) === String(v)) ? { ...r, ...patch } : r); + localDb.write(db); + return; + } + let query = supabase.from(table).update(patch); + Object.entries(match).forEach(([k, v]) => { query = query.eq(k, v); }); + const { error } = await query; + if (await maybeSwitchToLocalMode(error)) return dbUpdate(table, match, patch); + if (error) throw error; +} + +async function dbDeleteAll(table) { + if (state.mode === 'local') { + const db = localDb.read(); + db[table] = []; + localDb.write(db); + return; + } + const { error } = await supabase.from(table).delete().neq('id', 0); + if (await maybeSwitchToLocalMode(error)) return dbDeleteAll(table); + if (error) throw error; +} + +function normalizeProductCode(code) { + const digits = String(code || '').replace(/\D/g, ''); + if (!digits) return String(code || ''); + const idx2 = digits.indexOf('2'); + if (idx2 >= 0) return digits.slice(idx2); + return digits.replace(/^0+/, '') || digits; +} + +function getFardosPorPalete(code) { + const normalized = Number(normalizeProductCode(code)); + return FARDO_POR_PALETE[normalized] || 1; +} + +function setAuthView(view) { + state.authView = view; + registerForm.classList.toggle('hidden', view !== 'register'); + loginForm.classList.toggle('hidden', view !== 'login'); +} + +function setUser(user) { + state.user = user; + if (user) localStorage.setItem('rcv-user', JSON.stringify(user)); + else { + localStorage.removeItem('rcv-user'); + qs('chatPanel').classList.add('hidden'); + } + renderAuthState(); + if (user) refreshAll(); +} + +function bootstrapUser() { + const saved = localStorage.getItem('rcv-user'); + if (saved) { + state.user = JSON.parse(saved); + renderAuthState(); + refreshAll(); + } +} + +async function verifySupabaseSchema() { + try { + await dbSelect('usuarios', { orderBy: 'created_at' }); + } catch (error) { + await maybeSwitchToLocalMode(error); + } +} + +function renderAuthState() { + const user = state.user; + const isAdm = user?.role === 'adm'; + authSection.classList.toggle('hidden', !!user); + adminSection.classList.toggle('hidden', !isAdm || logsSection.dataset.active === '1'); + logsSection.classList.toggle('hidden', !isAdm || logsSection.dataset.active !== '1'); + operacaoSection.classList.toggle('hidden', !user || user.role !== 'operacao'); + qs('chatBubble').classList.toggle('hidden', !user); + qs('btnLogs').classList.toggle('hidden', !isAdm); + qs('btnBackAdmin').classList.toggle('hidden', !isAdm || logsSection.dataset.active !== '1'); +} + +function setAdminTab(tab) { + state.adminTab = tab; + document.querySelectorAll('[data-admin-tab]').forEach((btn) => btn.classList.toggle('active', btn.dataset.adminTab === tab)); + qs('tabAbertas').classList.toggle('hidden', tab !== 'abertas'); + qs('tabConferidas').classList.toggle('hidden', tab !== 'conferidas'); + qs('tabNq').classList.toggle('hidden', tab !== 'nq'); + qs('tabPlanilha').classList.toggle('hidden', tab !== 'planilha'); + qs('tabRecebimento').classList.toggle('hidden', tab !== 'recebimento'); +} + +function setLogsPage(active) { + logsSection.dataset.active = active ? '1' : '0'; + renderAuthState(); +} + +function extractDtRemessa(infCpl) { + return String(infCpl || '').match(/Numero\s*DT\s*:?\s*(\d+)/i)?.[1] || ''; +} + +function extractDocumentoSap(infCpl) { + return String(infCpl || '').match(/(?:Doc\.?\s*Referencia|Documento\s*SAP)\s*:?\s*([0-9A-Z]+)/i)?.[1] || '-'; +} + +function composeDateTimeFromHour(hourText) { + if (!hourText) return new Date().toISOString(); + const now = new Date(); + const [h, m] = String(hourText).split(':').map(Number); + now.setHours(Number.isFinite(h) ? h : now.getHours(), Number.isFinite(m) ? m : now.getMinutes(), 0, 0); + return now.toISOString(); +} + +function extractTransportInfo(xml, infCpl) { + const text = (tag) => xml.getElementsByTagName(tag)[0]?.textContent?.trim() || ''; + const transporta = xml.getElementsByTagName('transporta')[0]; + const motoristaInfCpl = String(infCpl || '').match(/(?:Motorista|Condutor)\s*:?\s*([A-ZÀ-Ú0-9 .'-]{3,60})/i)?.[1]?.trim(); + const motorista = motoristaInfCpl || text('xContato') || '-'; + const transportadora = transporta?.getElementsByTagName('xNome')[0]?.textContent?.trim() || '-'; + const telefoneTag = text('fone') || transporta?.getElementsByTagName('fone')[0]?.textContent?.trim() || ''; + const telefoneText = String(infCpl || '').match(/(\+?\d{10,14})/)?.[1] || ''; + const placaTag = text('placa') || text('xPlaca') || ''; + const placaText = String(infCpl || '').match(/\b[A-Z]{3}[0-9][A-Z0-9][0-9]{2}\b/i)?.[0] || ''; + return { transportadora, motorista, telefone: telefoneTag || telefoneText || '-', placa: placaTag || placaText || '-' }; +} + +function parseXmlText(xmlText) { + const parser = new DOMParser(); + const xml = parser.parseFromString(xmlText, 'application/xml'); + const text = (tag) => xml.getElementsByTagName(tag)[0]?.textContent?.trim() || ''; + const infCpl = text('infCpl'); + const items = Array.from(xml.getElementsByTagName('det')).map((det) => { + const prod = det.getElementsByTagName('prod')[0]; + const get = (tag) => prod?.getElementsByTagName(tag)[0]?.textContent?.trim() || ''; + return { + codigo: normalizeProductCode(get('cProd')), + descricao: get('xProd'), + quantidadeFardo: Number(get('qCom') || 0), + quantidadePalete: Number((Number(get('qCom') || 0) / 80).toFixed(2)), + ncm: get('NCM'), + cfop: get('CFOP'), + }; + }); + + return { + numeroNota: text('nNF') || text('chNFe').slice(-9), + chave: text('chNFe'), + emissao: text('dhEmi'), + emitente: text('xNome'), + emitenteCnpj: text('CNPJ'), + destinatario: xml.getElementsByTagName('dest')[0]?.getElementsByTagName('xNome')[0]?.textContent?.trim() || '-', + destinatarioCnpj: xml.getElementsByTagName('dest')[0]?.getElementsByTagName('CNPJ')[0]?.textContent?.trim() || '-', + natureza: text('natOp'), + protocolo: text('nProt'), + valorTotal: Number(text('vNF') || text('vNFTot') || 0), + pesoBruto: text('pesoB') || '-', + volume: text('qVol') || '-', + dtRemessa: extractDtRemessa(infCpl), + documentoSap: extractDocumentoSap(infCpl), + items, + transporte: extractTransportInfo(xml, infCpl), + rawXml: xmlText, + }; +} + +async function registerUser(evt) { + evt.preventDefault(); + const f = new FormData(registerForm); + try { + await dbInsert('usuarios', { + matricula: f.get('matricula'), + senha: f.get('senha'), + role: f.get('role'), + email: f.get('email') || null, + telefone: f.get('telefone') || null, + }); + alert(`Usuário cadastrado com sucesso! (${state.mode.toUpperCase()})`); + registerForm.reset(); + setAuthView('login'); + } catch (error) { + alert(`Erro ao cadastrar: ${error.message}`); + } +} + +async function login(evt) { + evt.preventDefault(); + const f = new FormData(loginForm); + const data = await dbSelect('usuarios', { eq: { matricula: f.get('matricula'), senha: f.get('senha') }, single: true }); + if (!data) return alert('Matrícula ou senha inválida.'); + setUser(data); +} + +function renderPdfPages(nota) { + const p1 = qs('pdfPage1'); + const p2 = qs('pdfPage2'); + if (!p1 || !p2 || !nota) return; + const nfeFmt = String(nota.numeroNota || '').padStart(9, '0').replace(/(\d{3})(\d{3})(\d{3})/, '$1.$2.$3'); + const chave = String(nota.chave || '').replace(/\D/g, ''); + + p1.innerHTML = `
PÁGINA 1/2 - DADOS DA NF-E
+
EMISSÃO: ${nota.emissao || '-'} | VALOR TOTAL: ${(nota.valorTotal || 0).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })}
NATUREZA: ${nota.natureza || '-'} | PROTOCOLO: ${nota.protocolo || '-'}
+
NF-e: ${nfeFmt}
Chave: ${nota.chave || '-'}
Transportadora: ${nota.transporte.transportadora}
Motorista: ${nota.transporte.motorista}
Telefone: ${nota.transporte.telefone}
Placa: ${nota.transporte.placa}
DT: ${nota.dtRemessa || '-'}
+
Emitente: ${nota.emitente} (${nota.emitenteCnpj})
Destinatário: ${nota.destinatario} (${nota.destinatarioCnpj})
Volume: ${nota.volume} | Peso: ${nota.pesoBruto}
+
`; + + p2.innerHTML = `
PÁGINA 2/2 - CONFERÊNCIA / PALETES
+ ${nota.items.map((i) => ``).join('')}
CódigoDescriçãoNCM/CFOPPaletes
${i.codigo}${i.descricao}${i.ncm || '-'} / ${i.cfop || '-'}
`; + + if (window.JsBarcode && chave.length === 44) window.JsBarcode('#barcodeChave', chave, { format: 'CODE128', width: 1.2, height: 44, displayValue: true, margin: 0 }); + + uploadXmlForm.elements.motorista.value = nota.transporte.motorista; + uploadXmlForm.elements.transportadora.value = nota.transporte.transportadora; + uploadXmlForm.elements.telefoneMotorista.value = nota.transporte.telefone; + uploadXmlForm.elements.placa.value = nota.transporte.placa; +} + +async function publishInvoice(evt) { + evt.preventDefault(); + const file = qs('xmlFile').files[0]; + if (!file) return alert('Selecione um XML.'); + const nota = parseXmlText(await file.text()); + const alreadyExists = await dbSelect('notas', { eq: { numero_nota: nota.numeroNota } }); + if ((alreadyExists || []).length) { + duplicateWarning?.classList.remove('hidden'); + await logAction('NOTA_DUPLICADA_BLOQUEADA', null, 0, `Tentativa de publicar NF ${nota.numeroNota} já existente`, false); + return alert(`A NF ${nota.numeroNota} já foi publicada. Operação bloqueada para evitar duplicidade.`); + } + await dbInsert('notas', { + numero_nota: nota.numeroNota, + chave_nfe: nota.chave, + motorista: nota.transporte.motorista, + transportadora: nota.transporte.transportadora, + telefone_motorista: nota.transporte.telefone, + placa: nota.transporte.placa, + emitente: nota.emitente, + emissao: nota.emissao, + valor_total: nota.valorTotal, + itens_json: nota.items, + xml_raw: nota.rawXml, + dt_remessa: nota.dtRemessa || '', + descarga_fechada: false, + publicado_por: state.user.matricula, + }); + state.currentInvoice = nota; + duplicateWarning?.classList.add('hidden'); + invoicePreview.textContent = JSON.stringify(nota, null, 2); + renderPdfPages(nota); + uploadXmlForm.reset(); + await logAction('PUBLICACAO_NOTA', null, 0, `Nota ${nota.numeroNota} publicada pelo ADM`, false); + await loadInvoices(); + alert('Nota publicada com sucesso.'); +} + +async function generatePdfFromPages() { + const { jsPDF } = window.jspdf || {}; + if (!jsPDF || !window.html2canvas) return alert('Bibliotecas PDF não carregadas.'); + const pages = [qs('pdfPage1'), qs('pdfPage2')]; + if (!pages[0].innerHTML.trim()) return alert('Carregue um XML antes de gerar PDF.'); + const pdf = new jsPDF('p', 'mm', 'a4'); + for (let i = 0; i < pages.length; i += 1) { + const canvas = await window.html2canvas(pages[i], { scale: 2, useCORS: true, backgroundColor: '#fff' }); + const img = canvas.toDataURL('image/png'); + const prop = pdf.getImageProperties(img); + const m = 6; + let w = 210 - m * 2; + let h = (prop.height * w) / prop.width; + if (h > 297 - m * 2) { + h = 297 - m * 2; + w = (prop.width * h) / prop.height; + } + if (i > 0) pdf.addPage(); + pdf.addImage(img, 'PNG', (210 - w) / 2, m, w, h); + } + + const labels = buildEtiquetaPages(state.currentInvoice); + labels.forEach((label) => { + pdf.addPage('a4', 'landscape'); + const width = 297; + pdf.setFont('helvetica', 'bold'); + pdf.setFontSize(62); + pdf.text(String(label.codigo), width / 2, 72, { align: 'center' }); + pdf.setFontSize(34); + pdf.text(`${label.fardosPorPalete} FARDOS POR PALETE`, width / 2, 105, { align: 'center' }); + pdf.setFontSize(18); + pdf.text(`Palete ${label.paleteIndex}/${label.totalPaletes} - Etiqueta ${label.copyIndex}/2`, width / 2, 125, { align: 'center' }); + pdf.setDrawColor(20, 20, 20); + pdf.rect(15, 140, width - 30, 48); + pdf.setFontSize(14); + pdf.text('Conferido por: ______________________', 25, 160); + pdf.text('Turno: ______________________', width - 110, 160); + pdf.text(`NF: ${state.currentInvoice?.numeroNota || '-'} SKU: ${label.codigo}`, 25, 178); + }); + pdf.save(`recebimento-nfe-${String(state.currentInvoice?.numeroNota || 'sem-nfe').padStart(9, '0').slice(-9)}.pdf`); +} + +function buildEtiquetaPages(nota) { + if (!nota?.items?.length) return []; + const pages = []; + nota.items.forEach((item) => { + const codigo = normalizeProductCode(item.codigo); + const fardosPorPalete = getFardosPorPalete(codigo); + const totalPaletes = Math.max(1, Math.ceil(Number(item.quantidadeFardo || 0) / fardosPorPalete)); + for (let palete = 1; palete <= totalPaletes; palete += 1) { + for (let copyIndex = 1; copyIndex <= 2; copyIndex += 1) { + pages.push({ codigo, fardosPorPalete, paleteIndex: palete, totalPaletes, copyIndex }); + } + } + }); + return pages; +} + +async function loadInvoices() { + const allInvoices = await dbSelect('notas', { orderBy: 'created_at' }) || []; + allInvoices.sort((a, b) => new Date(a.created_at || 0) - new Date(b.created_at || 0)); + state.invoices = allInvoices; + notaSelect.innerHTML = ''; + let visibleInvoices = allInvoices; + if (state.user?.role === 'operacao') { + const todasConferencias = await dbSelect('conferencias') || []; + const notasJaConferidas = new Set(todasConferencias.map((c) => String(c.nota_id))); + visibleInvoices = allInvoices.filter((n) => n.reaberta || !notasJaConferidas.has(String(n.id))); + if (operacaoStats) operacaoStats.textContent = `Notas pendentes para você: ${visibleInvoices.length} de ${allInvoices.length}`; + } + + visibleInvoices.forEach((n) => { + const opt = document.createElement('option'); + opt.value = n.id; + const placaTag = `${n.placa || 'SEM-PLACA'}${n.reaberta ? '-reaberta' : ''}`; + opt.textContent = `${n.numero_nota}/${placaTag}/${n.dt_remessa || '-'}`; + notaSelect.appendChild(opt); + }); + if (state.user?.role === 'operacao' && !visibleInvoices.length) { + notaInfo.innerHTML = '

Tudo conferido ✅

'; + conferenciaForm.innerHTML = ''; + return; + } + if (visibleInvoices.length && state.user?.role === 'operacao') renderOperacaoInvoice(visibleInvoices[0].id); +} + +function renderOperacaoInvoice(notaId) { + const nota = state.invoices.find((n) => String(n.id) === String(notaId)); + if (!nota) { + notaInfo.textContent = 'Nenhuma nota disponível'; + conferenciaForm.innerHTML = ''; + return; + } + notaInfo.innerHTML = `

Transportadora: ${nota.transportadora || '-'}

Motorista: ${nota.motorista}

Telefone: ${nota.telefone_motorista}

Placa: ${nota.placa}

DT/Remessa: ${nota.dt_remessa || '-'}

${nota.reaberta ? '

Carga reaberta pelo ADM - refaça a conferência.

' : ''}`; + conferenciaForm.innerHTML = ` +
+ Unidade da conferência (bem visível) + + +
+ + + + + + + `; + + (nota.itens_json || []).forEach((item) => { + const code = normalizeProductCode(item.codigo); + const fardosPorPalete = getFardosPorPalete(code); + conferenciaForm.insertAdjacentHTML('beforeend', `

${code} - ${item.descricao}

${fardosPorPalete} fardos por palete

`); + }); + + conferenciaForm.insertAdjacentHTML('beforeend', ''); + conferenciaForm.dataset.notaId = nota.id; + + conferenciaForm.querySelectorAll('input[name="unidade_conferencia"]').forEach((radio) => { + radio.addEventListener('change', () => { + const paleteMode = conferenciaForm.querySelector('input[name="unidade_conferencia"]:checked')?.value === 'palete'; + conferenciaForm.querySelectorAll('[data-fracao]').forEach((el) => el.classList.toggle('hidden', !paleteMode)); + }); + }); +} + +async function submitConferencia(evt) { + evt.preventDefault(); + const nota = state.invoices.find((n) => String(n.id) === String(conferenciaForm.dataset.notaId)); + if (!nota) return; + const existingForNote = await dbSelect('conferencias', { eq: { nota_id: nota.id } }) || []; + if (existingForNote.length && !nota.reaberta) { + return alert('Esta nota já foi conferida por outro usuário. Apenas notas reabertas pelo ADM podem ser conferidas novamente.'); + } + if (!confirm('Tem certeza que deseja terminar a conferência?')) return; + + const form = new FormData(conferenciaForm); + const houveAvaria = form.get('avaria') === 'on'; + const houveFaltaMarcada = form.get('faltando') === 'on'; + const pl2 = form.get('pl2') === 'on'; + const paletesTotal = Number(form.get('paletes_total') || 0); + const unidade = form.get('unidade_conferencia') || 'fardo'; + const inicioCarga = composeDateTimeFromHour(form.get('hora_inicio')) || nota.inicio_descarga || new Date().toISOString(); + const fimCarga = composeDateTimeFromHour(form.get('hora_fim')); + const conferidos = (nota.itens_json || []).map((item) => { + const code = normalizeProductCode(item.codigo); + const informadoRaw = Number(form.get(code) || 0); + const fracaoFardo = Number(form.get(`${code}__fracao`) || 0); + const fatorPalete = getFardosPorPalete(code); + const informadoFardo = unidade === 'palete' ? Number(((informadoRaw * fatorPalete) + fracaoFardo).toFixed(2)) : informadoRaw; + return { + ...item, + codigo: code, + conferido: informadoFardo, + conferido_raw: informadoRaw, + fracao_fardos: fracaoFardo, + unidade_conferencia: unidade, + fator_palete: fatorPalete, + divergencia: Number((informadoFardo - Number(item.quantidadeFardo)).toFixed(2)), + }; + }); + + const divergentes = conferidos.filter((i) => i.divergencia !== 0); + if (divergentes.length && !confirm(`Há divergência nos códigos: ${divergentes.map((d) => d.codigo).join(', ')}. Finalizar mesmo assim?`)) return; + + await dbInsert('conferencias', { + nota_id: nota.id, + conferente_matricula: state.user.matricula, + observacao: `${form.get('observacao') || ''}`.trim(), + avaria: houveAvaria, + faltando: houveFaltaMarcada, + pl2, + paletes_total: paletesTotal, + inicio_carga: inicioCarga, + fim_carga: fimCarga, + reabertura_finalizada: !!nota.reaberta, + avaria_obs: `${form.get('avaria_obs') || ''}`.trim(), + status: divergentes.length ? 'com_divergencia' : 'ok', + itens_conferidos: conferidos, + }); + + await dbUpdate('notas', { id: nota.id }, { + inicio_descarga: inicioCarga, + fim_descarga: fimCarga, + pl2, + paletes_total: paletesTotal, + reaberta: false, + anotacao_reabertura: nota.reaberta ? `Reaberta pelo ADM e finalizada pela operação em ${new Date(fimCarga).toLocaleString('pt-BR')}` : (nota.anotacao_reabertura || ''), + }); + + for (const item of conferidos) { + await dbInsert('nq_reports', { + data_ref: new Date().toISOString(), + placa: nota.placa || '-', + remessa: nota.dt_remessa || '-', + nf: nota.numero_nota, + cd_origem: 'Mogi', + sku: item.codigo, + qtde_nf: item.quantidadeFardo ?? 0, + qtd_rec_fisico: item.conferido ?? 0, + avaria: houveAvaria, + faltando: houveFaltaMarcada || Number(item.conferido) < Number(item.quantidadeFardo), + criado_por: state.user.matricula, + }); + } + + if (houveFaltaMarcada) await logAction('FALTA_INFORMADA', null, 0, `Nota ${nota.numero_nota} marcada com falta`, false); + for (const item of divergentes) await logAction('DIVERGENCIA', item.codigo, item.divergencia, `Nota ${nota.numero_nota}`, true); + if (conferidos.some((item) => item.divergencia > 0)) await logAction('QUANTIDADE_A_MAIS', null, 0, `Nota ${nota.numero_nota} com itens a mais`, false); + if (nota.reaberta) await logAction('REABERTURA_FINALIZADA', null, 0, `Nota ${nota.numero_nota} finalizada após reabertura`, false); + if (!divergentes.length) await logAction('CONFERENCIA_OK', null, 0, `Nota ${nota.numero_nota} sem divergências`, false); + + alert('Conferência enviada com sucesso.'); + await loadInvoices(); +} + +async function fecharDescarga(idNota) { + const nota = state.invoices.find((n) => n.id === idNota); + await dbUpdate('notas', { id: idNota }, { descarga_fechada: true, fim_descarga: nota?.fim_descarga || new Date().toISOString() }); + await logAction('DESCARGA_FECHADA', null, 0, `Descarga NF ${state.invoices.find((n) => n.id === idNota)?.numero_nota || '-' } fechada pelo ADM`, false); + await refreshAll(); +} + +async function reabrirConferencia(idNota) { + await dbUpdate('notas', { id: idNota }, { reaberta: true, descarga_fechada: false, reaberta_em: new Date().toISOString() }); + await logAction('CONFERENCIA_REABERTA', null, 0, `NF ${state.invoices.find((n) => n.id === idNota)?.numero_nota || '-'} reaberta pelo ADM`, false); + await refreshAll(); +} + +async function loadConferencias() { + const data = await dbSelect('conferencias', { orderBy: 'created_at' }); + adminConferencias.innerHTML = ''; + (data || []).forEach((conf) => { + const nota = state.invoices.find((n) => n.id === conf.nota_id); + const divergencias = (conf.itens_conferidos || []).filter((i) => i.divergencia !== 0); + const houveExcesso = (conf.itens_conferidos || []).some((i) => Number(i.divergencia) > 0); + const card = document.createElement('div'); + card.className = `conferencia ${divergencias.length ? 'divergente' : ''} ${conf.faltando || houveExcesso ? 'faltando' : ''} ${nota?.reaberta ? 'reaberta' : ''}`; + card.innerHTML = ` +

Conferente: ${conf.conferente_matricula}

+

Status: ${conf.status}

+

DT/NF: ${nota?.dt_remessa || '-'} / NF ${nota?.numero_nota || '-'}

+

Avaria: ${conf.avaria ? 'Sim' : 'Não'} | Falta: ${conf.faltando ? 'Sim' : 'Não'}

+

PL2: ${conf.pl2 ? 'Sim' : 'Não'} | Paletes: ${conf.paletes_total || '-'}

+

Início carga: ${conf.inicio_carga ? new Date(conf.inicio_carga).toLocaleString('pt-BR') : '-'} | Fim: ${conf.fim_carga ? new Date(conf.fim_carga).toLocaleString('pt-BR') : '-'}

+

Observação: ${conf.observacao || '-'}

+

Divergências: ${divergencias.map((d) => `${d.codigo} (${d.divergencia})`).join(', ') || 'Nenhuma'}

+ ${nota?.anotacao_reabertura ? `

Reabertura: ${nota.anotacao_reabertura}

` : ''} + ${nota?.descarga_fechada ? '

Descarga: Fechada

' : ``} + `; + adminConferencias.appendChild(card); + }); + + document.querySelectorAll('[data-nota-close]').forEach((btn) => { + btn.addEventListener('click', () => fecharDescarga(Number(btn.dataset.notaClose))); + }); + document.querySelectorAll('[data-nota-reopen]').forEach((btn) => { + btn.addEventListener('click', () => reabrirConferencia(Number(btn.dataset.notaReopen))); + }); +} + +async function loadNqReports() { + if (!adminNqContainer) return; + const data = await dbSelect('nq_reports', { orderBy: 'data_ref' }); + adminNqContainer.innerHTML = ''; + state.nqLines = []; + (data || []).forEach((r) => { + const line = [new Date(r.data_ref).toLocaleDateString('pt-BR'), r.placa || '-', r.remessa || '-', r.nf || '-', r.cd_origem || 'Mogi', r.sku || '-', r.qtde_nf ?? 0, r.qtd_rec_fisico ?? 0].join('\t'); + state.nqLines.push(line); + const div = document.createElement('div'); + div.className = `log ${r.faltando ? 'faltando' : ''} ${r.avaria ? 'divergente' : ''}`; + div.innerHTML = `${line}`; + adminNqContainer.appendChild(div); + }); + + document.querySelectorAll('.copy-line').forEach((btn) => { + btn.addEventListener('click', async () => { + await navigator.clipboard.writeText(decodeURIComponent(btn.dataset.copy)); + btn.textContent = 'Copiado!'; + setTimeout(() => { btn.textContent = 'Copiar linha'; }, 1200); + }); + }); +} + +function loadDescargaPlanilha() { + if (!adminDescargasContainer) return; + const rows = [...state.invoices] + .sort((a, b) => new Date(a.created_at || 0) - new Date(b.created_at || 0)) + .map((nota) => ` + + ${nota.dt_remessa || '-'} + ${nota.placa || '-'} + ${nota.motorista || '-'} + ${nota.numero_nota || '-'} + ${nota.inicio_descarga ? new Date(nota.inicio_descarga).toLocaleString('pt-BR') : '-'} + ${nota.fim_descarga ? new Date(nota.fim_descarga).toLocaleString('pt-BR') : '-'} + ${nota.pl2 ? 'Sim' : 'Não'} + ${nota.paletes_total || '-'} + + `).join(''); + + adminDescargasContainer.innerHTML = ` + + + + + + + + + + + + + + ${rows} +
DTPlacaMotoristaNFInício cargaTérminoPL2Paletes
+ `; +} + +function persistRecebimentoMap() { + localStorage.setItem('rcv-recebimento-map', JSON.stringify(state.recebimentoMap)); +} + +function buildRecebimentoLine(nota, extra = {}) { + const referencia = nota?.xml_raw ? parseXmlText(nota.xml_raw) : null; + const chegada = new Date(nota?.created_at || Date.now()); + const data = chegada.toLocaleDateString('pt-BR'); + const hora = extra.hora_chegada || chegada.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }); + const peso = referencia?.pesoBruto || '-'; + const remessa = nota?.dt_remessa || referencia?.dtRemessa || '-'; + const documentoSap = referencia?.documentoSap || '-'; + const status = extra.status || (nota?.descarga_fechada ? 'FECHADA' : (nota?.reaberta ? 'REABERTA' : 'ABERTA')); + const horaEncerrada = extra.hora_encerrada || (nota?.fim_descarga ? new Date(nota.fim_descarga).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }) : '-'); + return [ + data, + hora, + nota?.numero_nota || '-', + peso, + remessa, + documentoSap, + extra.portaria_sap || '-', + status, + extra.origem || '-', + extra.setor || '-', + extra.turno || '-', + horaEncerrada, + extra.pendencias || '-', + ].join('\t'); +} + +function loadRecebimentoTab() { + if (!adminRecebimentoContainer) return; + const rows = [...state.invoices] + .sort((a, b) => new Date(a.created_at || 0) - new Date(b.created_at || 0)) + .map((nota) => { + const saved = state.recebimentoMap[String(nota.id)] || {}; + const line = buildRecebimentoLine(nota, saved); + return `
+
+ + + + + + + + +
+ ${line} + +
`; + }).join(''); + + adminRecebimentoContainer.innerHTML = rows || '

Sem notas publicadas.

'; + + document.querySelectorAll('[data-rec-field]').forEach((el) => { + el.addEventListener('input', (evt) => { + const noteId = String(evt.target.dataset.noteId); + const field = evt.target.dataset.recField; + const current = state.recebimentoMap[noteId] || {}; + state.recebimentoMap[noteId] = { ...current, [field]: evt.target.value.trim() }; + persistRecebimentoMap(); + loadRecebimentoTab(); + }); + }); + + document.querySelectorAll('.copy-recebimento-line').forEach((btn) => { + btn.addEventListener('click', async () => { + const noteId = String(btn.dataset.noteId); + const nota = state.invoices.find((x) => String(x.id) === noteId); + const line = buildRecebimentoLine(nota, state.recebimentoMap[noteId] || {}); + await navigator.clipboard.writeText(line); + btn.textContent = 'Copiado!'; + setTimeout(() => { btn.textContent = 'Copiar linha'; }, 1200); + }); + }); +} + +function formatFileTimestamp(date = new Date()) { + const pad = (n) => String(n).padStart(2, '0'); + return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`; +} + +async function guardarLogsNoTerabox() { + if (!state.user || state.user.role !== 'adm') return alert('Apenas ADM pode guardar logs no TeraBox.'); + if (!state.logs.length) return alert('Sem logs para guardar.'); + const header = 'DataHora\tUsuario\tTipo\tCodigo\tQtdDivergencia\tMensagem'; + const lines = state.logs.map((log) => [ + new Date(log.created_at).toISOString(), + log.usuario_matricula || '-', + log.tipo || '-', + log.codigo || '-', + log.quantidade_divergencia ?? 0, + (log.mensagem || '').replace(/\n/g, ' ').trim(), + ].join('\t')); + const content = `${header}\n${lines.join('\n')}`; + const fileName = `logs-carregamento-${formatFileTimestamp()}.txt`; + try { + const resp = await fetch(`${BACKEND_BASE_URL}/exportar-txt-terabox`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ fileName, content }), + }); + const data = await resp.json(); + if (!resp.ok || !data.success) throw new Error(data.error || 'Falha no backend'); + alert(`Logs guardados no TeraBox com sucesso: ${fileName}`); + } catch (error) { + alert(`Falha ao guardar logs no TeraBox. Verifique backend/URL e autenticação.\nBackend atual: ${BACKEND_BASE_URL}\nErro: ${error.message}`); + } +} + +async function logAction(tipo, codigo, quantidadeDivergencia, mensagem, divergente) { + await dbInsert('logs', { tipo, codigo, quantidade_divergencia: quantidadeDivergencia, mensagem, divergente, usuario_matricula: state.user?.matricula || 'sistema' }); + await loadLogs(); +} + +function renderLogs() { + const search = (qs('logSearch')?.value || '').toLowerCase().trim(); + logsContainer.innerHTML = ''; + + state.logs + .filter((log) => { + if (state.logFilter === 'divergente') return !!log.divergente; + if (state.logFilter === 'falta') return log.tipo === 'FALTA_INFORMADA' || log.tipo === 'QUANTIDADE_A_MAIS'; + if (state.logFilter === 'ok') return !log.divergente && log.tipo !== 'FALTA_INFORMADA' && log.tipo !== 'QUANTIDADE_A_MAIS'; + return true; + }) + .filter((log) => `${log.tipo} ${log.usuario_matricula} ${log.codigo || ''} ${log.mensagem || ''}`.toLowerCase().includes(search)) + .forEach((log) => { + const row = document.createElement('div'); + row.className = `log ${log.divergente ? 'divergente' : ''} ${log.tipo === 'FALTA_INFORMADA' || log.tipo === 'QUANTIDADE_A_MAIS' ? 'faltando' : ''}`; + row.innerHTML = `${new Date(log.created_at).toLocaleString('pt-BR')} - usuário ${log.usuario_matricula} - ${log.tipo} - cód: ${log.codigo || '-'} - divergência: ${log.quantidade_divergencia || 0}
${log.mensagem || ''}`; + logsContainer.appendChild(row); + }); +} + +async function loadLogs() { + state.logs = await dbSelect('logs', { orderBy: 'created_at' }) || []; + renderLogs(); +} + +async function recoverBySms() { + const matricula = prompt('Informe a matrícula'); + if (!matricula) return; + const telefone = prompt('Informe o telefone cadastrado (com DDI)'); + if (!telefone) return; + const novaSenha = Math.random().toString(36).slice(-8); + try { + await dbUpdate('usuarios', { matricula, telefone }, { senha: novaSenha }); + if (state.mode === 'supabase') { + const { error } = await supabase.functions.invoke('send-sms', { body: { to: telefone, message: `Nova senha: ${novaSenha}` } }); + if (error) throw error; + } + alert(`Senha resetada. Nova senha: ${novaSenha}`); + } catch (error) { + alert(`SMS não funcionou. Configure send-sms no Supabase (Twilio).\nErro: ${error.message}`); + } +} + +async function recoverByEmail() { + const matricula = prompt('Informe a matrícula'); + if (!matricula) return; + const novaSenha = Math.random().toString(36).slice(-8); + try { + await dbUpdate('usuarios', { matricula }, { senha: novaSenha }); + if (state.mode === 'supabase') { + const { error } = await supabase.functions.invoke('send-email', { body: { to: 'leseliv487@fengnu.com', subject: 'Nova senha', text: `Nova senha: ${novaSenha}` } }); + if (error) throw error; + } + alert(`Senha resetada. Nova senha: ${novaSenha}`); + } catch (error) { + alert(`E-mail não funcionou. Configure send-email no Supabase (Resend/SMTP).\nErro: ${error.message}`); + } +} + +function exportLogs() { + const blob = new Blob([JSON.stringify(state.logs, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `logs-${new Date().toISOString()}.json`; + a.click(); + URL.revokeObjectURL(url); +} + +async function clearLogs() { + if (confirm('Deseja apagar todos os logs?')) { + await dbDeleteAll('logs'); + await loadLogs(); + } +} + +function toggleChat() { qs('chatPanel').classList.toggle('hidden'); } + +async function sendChat(evt) { + evt.preventDefault(); + const text = qs('chatInput').value.trim(); + if (!text) return; + await dbInsert('chats', { from_matricula: state.user.matricula, from_role: state.user.role, to_role: state.user.role === 'adm' ? 'operacao' : 'adm', mensagem: text }); + qs('chatInput').value = ''; + await loadChats(); +} + +async function loadChats() { + if (!state.user) return; + const data = await dbSelect('chats', { orderBy: 'created_at' }); + const filtered = (data || []).filter((m) => m.to_role === state.user.role || m.from_matricula === state.user.matricula); + const container = qs('chatMessages'); + container.innerHTML = ''; + filtered.slice().reverse().forEach((m) => { + const p = document.createElement('p'); + p.className = m.from_matricula === state.user.matricula ? 'mine' : 'theirs'; + p.textContent = `${m.from_matricula}: ${m.mensagem}`; + container.appendChild(p); + }); +} + +async function refreshAll() { + await loadInvoices(); + if (state.user?.role === 'adm') { + await loadLogs(); + await loadConferencias(); + await loadNqReports(); + loadDescargaPlanilha(); + loadRecebimentoTab(); + } + await loadChats(); +} + +registerForm.addEventListener('submit', registerUser); +loginForm.addEventListener('submit', login); +uploadXmlForm?.addEventListener('submit', publishInvoice); +conferenciaForm.addEventListener('submit', submitConferencia); +notaSelect.addEventListener('change', (evt) => renderOperacaoInvoice(evt.target.value)); +qs('showLogin').addEventListener('click', () => setAuthView('login')); +qs('showRegister').addEventListener('click', () => setAuthView('register')); +qs('recoverSms').addEventListener('click', recoverBySms); +qs('recoverEmail').addEventListener('click', recoverByEmail); +qs('btnExportLogs').addEventListener('click', exportLogs); +qs('btnClearLogs').addEventListener('click', clearLogs); +qs('btnLogout').addEventListener('click', () => setUser(null)); +qs('btnPrintInvoice')?.addEventListener('click', generatePdfFromPages); +qs('chatBubble').addEventListener('click', toggleChat); +qs('chatForm').addEventListener('submit', sendChat); +qs('btnLogs').addEventListener('click', () => setLogsPage(true)); +qs('btnBackAdmin').addEventListener('click', () => setLogsPage(false)); +qs('logSearch').addEventListener('input', renderLogs); +qs('btnGuardarTerabox')?.addEventListener('click', guardarLogsNoTerabox); + +qs('btnDensity').addEventListener('click', () => { + document.body.classList.toggle('tablet-mode'); + qs('btnDensity').textContent = document.body.classList.contains('tablet-mode') ? 'Modo desktop' : 'Modo tablet'; +}); + +qs('xmlFile')?.addEventListener('change', async (evt) => { + const file = evt.target.files?.[0]; + if (!file) return; + const nota = parseXmlText(await file.text()); + const alreadyExists = await dbSelect('notas', { eq: { numero_nota: nota.numeroNota } }); + duplicateWarning?.classList.toggle('hidden', !(alreadyExists || []).length); + state.currentInvoice = nota; + invoicePreview.textContent = JSON.stringify(nota, null, 2); + renderPdfPages(nota); +}); + +document.querySelectorAll('[data-admin-tab]').forEach((btn) => { + btn.addEventListener('click', () => setAdminTab(btn.dataset.adminTab)); +}); + +document.querySelectorAll('[data-log-filter]').forEach((btn) => { + btn.addEventListener('click', () => { + state.logFilter = btn.dataset.logFilter; + document.querySelectorAll('[data-log-filter]').forEach((x) => x.classList.toggle('active', x.dataset.logFilter === state.logFilter)); + renderLogs(); + }); +}); + +setInterval(() => { + if (!state.user) return; + loadChats(); + if (state.user.role === 'adm') { + loadConferencias(); + loadLogs(); + loadNqReports(); + loadDescargaPlanilha(); + loadRecebimentoTab(); + } +}, 6000); + +(async () => { + setLogsPage(false); + setAdminTab('abertas'); + await verifySupabaseSchema(); + setAuthView('login'); + bootstrapUser(); +})(); diff --git a/package.json b/package.json new file mode 100644 index 0000000..50a9270 --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "recebimento-terabox-backend", + "version": "1.0.0", + "private": true, + "main": "server.js", + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "axios": "^1.8.4", + "cors": "^2.8.5", + "dotenv": "^16.4.7", + "express": "^4.21.2", + "form-data": "^4.0.0" + } +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..82a45c5 --- /dev/null +++ b/server.js @@ -0,0 +1,170 @@ +require('dotenv').config(); + +const express = require('express'); +const axios = require('axios'); +const cors = require('cors'); +const crypto = require('crypto'); +const FormData = require('form-data'); + +const app = express(); +app.use(cors()); +app.use(express.json({ limit: '10mb' })); + +const PORT = process.env.PORT || 3000; +const CLIENT_ID = process.env.TERABOX_CLIENT_ID; +const CLIENT_SECRET = process.env.TERABOX_CLIENT_SECRET; +const REDIRECT_URI = process.env.TERABOX_REDIRECT_URI; +const TARGET_DIR = process.env.TERABOX_TARGET_DIR || '/Apps/RecebimentoJSL'; + +const TERABOX_AUTH_BASE = 'https://www.terabox.com'; +const TERABOX_API_BASE = 'https://www.terabox.com/rest/2.0'; + +let teraboxToken = null; + +function ensureConfig() { + if (!CLIENT_ID || !CLIENT_SECRET || !REDIRECT_URI) { + const err = new Error('Variáveis TERABOX_CLIENT_ID/SECRET/REDIRECT_URI não configuradas.'); + err.statusCode = 500; + throw err; + } +} + +function ensureToken() { + if (!teraboxToken) { + const err = new Error('TeraBox não autenticado. Acesse /auth/terabox primeiro.'); + err.statusCode = 401; + throw err; + } +} + +function buildTxtBuffer(content) { + return Buffer.from(content, 'utf8'); +} + +function sha1Buffer(buffer) { + return crypto.createHash('sha1').update(buffer).digest('hex'); +} + +async function preUpload({ accessToken, fileName, fileBuffer }) { + const filePath = `${TARGET_DIR}/${fileName}`; + const response = await axios.post(`${TERABOX_API_BASE}/xpan/file`, null, { + params: { + method: 'precreate', + access_token: accessToken, + path: filePath, + size: fileBuffer.length, + isdir: 0, + autoinit: 1, + block_list: JSON.stringify([sha1Buffer(fileBuffer)]), + }, + }); + return response.data; +} + +async function uploadPart({ accessToken, uploadId, filePath, fileBuffer, partSeq = 0 }) { + const formData = new FormData(); + formData.append('file', fileBuffer, { filename: 'part0' }); + + const response = await axios.post(`${TERABOX_API_BASE}/pcs/superfile2`, formData, { + params: { + method: 'upload', + type: 'tmpfile', + access_token: accessToken, + path: filePath, + uploadid: uploadId, + partseq: partSeq, + }, + headers: formData.getHeaders(), + maxBodyLength: Infinity, + }); + + return response.data; +} + +async function createFile({ accessToken, fileName, fileBuffer, uploadId }) { + const filePath = `${TARGET_DIR}/${fileName}`; + const response = await axios.post(`${TERABOX_API_BASE}/xpan/file`, null, { + params: { + method: 'create', + access_token: accessToken, + path: filePath, + size: fileBuffer.length, + isdir: 0, + uploadid: uploadId, + block_list: JSON.stringify([sha1Buffer(fileBuffer)]), + }, + }); + return response.data; +} + +app.get('/health', (req, res) => { + res.json({ ok: true, teraboxConnected: !!teraboxToken }); +}); + +app.get('/auth/terabox', (req, res) => { + try { + ensureConfig(); + const authUrl = `${TERABOX_AUTH_BASE}/oauth2/authorize?response_type=code&client_id=${encodeURIComponent(CLIENT_ID)}&redirect_uri=${encodeURIComponent(REDIRECT_URI)}`; + res.redirect(authUrl); + } catch (error) { + res.status(error.statusCode || 500).send(error.message); + } +}); + +app.get('/auth/terabox/callback', async (req, res) => { + try { + ensureConfig(); + const { code } = req.query; + if (!code) return res.status(400).send('Código de autorização não recebido.'); + + const tokenResponse = await axios.post(`${TERABOX_AUTH_BASE}/oauth2/token`, null, { + params: { + grant_type: 'authorization_code', + code, + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + redirect_uri: REDIRECT_URI, + }, + }); + + teraboxToken = tokenResponse.data.access_token; + res.send('TeraBox conectado com sucesso.'); + } catch (error) { + console.error('Erro callback TeraBox:', error?.response?.data || error.message); + res.status(500).send('Erro ao autenticar com o TeraBox.'); + } +}); + +app.post('/exportar-txt-terabox', async (req, res) => { + try { + ensureConfig(); + ensureToken(); + + const { fileName, content } = req.body; + if (!fileName || !content) { + return res.status(400).json({ success: false, error: 'fileName e content são obrigatórios.' }); + } + + const fileBuffer = buildTxtBuffer(content); + const filePath = `${TARGET_DIR}/${fileName}`; + + const pre = await preUpload({ accessToken: teraboxToken, fileName, fileBuffer }); + const uploadId = pre.uploadid || pre.uploadId; + if (!uploadId) { + return res.status(500).json({ success: false, error: 'Pre-upload sem uploadId.', details: pre }); + } + + const uploaded = await uploadPart({ accessToken: teraboxToken, uploadId, filePath, fileBuffer, partSeq: 0 }); + const created = await createFile({ accessToken: teraboxToken, fileName, fileBuffer, uploadId }); + + return res.json({ success: true, message: 'TXT enviado ao TeraBox com sucesso.', preUpload: pre, uploadPart: uploaded, createFile: created }); + } catch (error) { + console.error('Erro exportar TXT TeraBox:', error?.response?.data || error.message); + const status = error.statusCode || error?.response?.status || 500; + return res.status(status).json({ success: false, error: 'Falha ao enviar TXT ao TeraBox.', details: error?.response?.data || error.message }); + } +}); + +app.listen(PORT, () => { + console.log(`Servidor backend em http://localhost:${PORT}`); +}); diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..335fb72 --- /dev/null +++ b/styles.css @@ -0,0 +1,60 @@ +:root { color-scheme: light; font-family: Arial, sans-serif; } +body { margin: 0; background: #f5f7fb; } +header { background: #0f2747; color: white; padding: 12px 20px; display: flex; justify-content: space-between; align-items: center; } +.brand { display:flex; gap:12px; align-items:center; } +.brand img { height: 34px; background:#fff; border-radius:6px; padding:4px; } +main { padding: 20px; } +.card { background: white; border-radius: 10px; padding: 16px; margin-bottom: 16px; box-shadow: 0 2px 6px rgba(0,0,0,.08); } +.grid.two { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 16px; } +.stack { display: flex; flex-direction: column; gap: 10px; } +label { font-size: 14px; display: flex; flex-direction: column; gap: 4px; } +input, select, textarea, button { padding: 8px; border-radius: 8px; border: 1px solid #c4c9d4; } +button { cursor: pointer; border: none; background: #2766cf; color: white; } +button.danger { background: #ca2e2e; } +.toolbar { display: flex; gap: 8px; align-items: center; } +.hidden { display: none !important; } +.preview { max-height: 320px; overflow: auto; padding: 10px; background: #eff3fb; border-radius: 8px; } +.log, .conferencia { padding: 10px; border: 1px solid #dde2eb; border-radius: 8px; margin-bottom: 8px; } +.divergente { border-color: #e54343; background: #fff0f0; } +.faltando { border-color: #d7a700; background: #fff8d9; } +.item { border: 1px solid #e2e6ef; border-radius: 8px; padding: 10px; } + +.tabs { display: flex; gap: 8px; margin: 8px 0 14px; flex-wrap: wrap; } +.tab-btn { background: #e9eef8; color: #203254; border: 1px solid #b8c8e8; } +.tab-btn.active { background: #2766cf; color: #fff; border-color: #2766cf; } +.copy-line { margin-left: 8px; background: #1f8a3b; } +.btn-close { background: #2e7d32; } +.btn-reopen { background: #c07a00; margin-left: 8px; } +.logs-toolbar input { min-width: 280px; flex: 1; } +.reaberta { border-color: #7e57c2 !important; background: #f1e9ff !important; } + +.auth-choice { display:flex; gap:8px; margin-bottom:12px; } +.auth-form { max-width: 460px; } +.hint { margin:0; font-size:12px; color:#526177; } +.warning { background: #fff3cd; border: 1px solid #ffcf66; color: #7d5900; padding: 8px; border-radius: 8px; } +.counter { margin: 0 0 12px; background: #e8f3ff; border: 1px solid #bcd6f8; color: #113764; padding: 8px 10px; border-radius: 8px; font-weight: 700; } +.unit-choice { background: #fff8d9; border: 2px solid #d7a700; border-radius: 8px; padding: 10px; display: flex; flex-direction: column; gap: 6px; } + +.chat-bubble { position: fixed; right: 20px; bottom: 20px; border-radius: 999px; width: 54px; height: 54px; font-size: 20px; } +.chat-panel { position: fixed; right: 20px; bottom: 84px; width: 320px; background: white; border: 1px solid #d8deea; border-radius: 8px; padding: 12px; } +.chat-messages { min-height: 220px; max-height: 220px; overflow: auto; background: #f2f5fb; border-radius: 8px; padding: 8px; } +.chat-messages p { margin: 4px 0; padding: 6px; border-radius: 6px; } +.chat-messages p.mine { background: #ddebff; } +.chat-messages p.theirs { background: #fff; } +.chat-form { margin-top: 8px; display: flex; gap: 8px; } + +.pdf-pages { margin-top: 12px; border-top: 1px dashed #cfd7df; padding-top: 12px; } +.pdf-page { width: 210mm; min-height: 297mm; margin: 0 auto 10px; background: #fff; border: 1px solid #ddd; padding: 10mm; box-sizing: border-box; } +.nf-box { border: 1px solid #111; padding: 6px; margin-bottom: 8px; } +.nf-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; } +.pdf-mini { font-size: 11px; line-height: 1.35; } +.barcode-wrap { text-align: center; margin: 8px 0; } + +body.tablet-mode .card { max-width: 900px; margin-left: auto; margin-right: auto; } +body.tablet-mode .grid.two { grid-template-columns: 1fr; } +body.tablet-mode .pdf-page { width: 100%; min-height: auto; } + +@media print { + .toolbar, #authSection, #operacaoSection, #chatBubble, #chatPanel, #uploadXmlForm, .preview, #btnPrintInvoice { display: none !important; } + .pdf-page { border: none; page-break-after: always; margin: 0; width: 100%; } +} diff --git a/supabase-schema.sql b/supabase-schema.sql new file mode 100644 index 0000000..657dee6 --- /dev/null +++ b/supabase-schema.sql @@ -0,0 +1,83 @@ +-- Execute no SQL Editor do Supabase (versão idempotente) +create table if not exists public.usuarios ( + id bigint generated always as identity primary key, + matricula text not null unique, + senha text not null, + role text not null check (role in ('adm', 'operacao')), + email text, + telefone text, + created_at timestamptz default now() +); + +create table if not exists public.notas ( + id bigint generated always as identity primary key, + numero_nota text not null, + chave_nfe text, + motorista text not null, + telefone_motorista text not null, + placa text not null, + emitente text, + emissao text, + valor_total numeric, + itens_json jsonb not null, + xml_raw text not null, + dt_remessa text default '', + descarga_fechada boolean default false, + publicado_por text not null, + created_at timestamptz default now() +); + +create table if not exists public.conferencias ( + id bigint generated always as identity primary key, + nota_id bigint not null references public.notas(id) on delete cascade, + conferente_matricula text not null, + observacao text, + status text not null, + avaria boolean default false, + faltando boolean default false, + avaria_obs text, + itens_conferidos jsonb not null, + created_at timestamptz default now() +); + +create table if not exists public.logs ( + id bigint generated always as identity primary key, + tipo text not null, + codigo text, + quantidade_divergencia numeric, + mensagem text, + divergente boolean default false, + usuario_matricula text not null, + created_at timestamptz default now() +); + +create table if not exists public.chats ( + id bigint generated always as identity primary key, + from_matricula text not null, + from_role text not null, + to_role text not null, + mensagem text not null, + created_at timestamptz default now() +); + +create table if not exists public.nq_reports ( + id bigint generated always as identity primary key, + data_ref timestamptz not null default now(), + placa text not null, + remessa text not null, + nf text not null, + cd_origem text not null default 'Mogi', + sku text not null, + qtde_nf numeric not null, + qtd_rec_fisico numeric not null, + avaria boolean default false, + faltando boolean default false, + criado_por text not null, + created_at timestamptz default now() +); + +alter table public.notas add column if not exists dt_remessa text default ''; +alter table public.notas add column if not exists descarga_fechada boolean default false; +alter table public.conferencias add column if not exists avaria boolean default false; +alter table public.conferencias add column if not exists faltando boolean default false; +alter table public.conferencias add column if not exists avaria_obs text;