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
+
+
+
+
+
+
+
+
+
+
+
+ Acesso
+
+ Entrar
+ Cadastrar
+
+
+
+
+
+
+
+
+ Painel ADM
+
+
+ Cargas abertas
+ Cargas conferidas
+ NQ Excel
+ Planilha descarga
+ Recebimento Excel
+
+
+
+
+
+
+
+
Visualização da nota
+
Nenhum XML carregado.
+
Gerar PDF (NF + Conferência + Etiquetas)
+
+
+
+
Pré-visualização PDF
+
+
+
+
+
+
+
Conferências recebidas
+
+
+
+
+
NQ para Excel (linha)
+
A operação só marca avaria e falta . Aqui no ADM você copia as linhas prontas para Excel.
+
+
+
+
+
Planilha de descarga
+
+
+
+
+
Recebimento Excel (linha)
+
Formato: DATA | Hora Chegada | NF | PESO | REMESSA | Documento SAP | PORTARIA SAP | STATUS | Origem | Setor | Turno | Hora encerrada | Pendências.
+
+
+
+
+
+ Página de Logs
+
+
+ Baixar logs
+ Guardar
+ Apagar logs
+
+
+ Todos
+ Divergências
+ Falta
+ Normais
+
+
+
+
+
+ Painel Operação
+
+
+
Escolha uma nota publicada
+
+
+
+
+
+
+ 💬
+
+
+
+
+
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
+ Código Descrição NCM/CFOP Paletes ${nota.items.map((i) => `${i.codigo} ${i.descricao} ${i.ncm || '-'} / ${i.cfop || '-'} `).join('')}
`;
+
+ 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)
+ Informar em FARDOS
+ Informar em PALETES
+
+ Carreta carregada com PL2
+ Quantidade de paletes (ex: 144)
+ Hora início descarga (editável)
+ Hora fim descarga (editável)
+ Veio avariado
+ Veio faltando
+ Descrição da avaria (opcional) `;
+
+ (nota.itens_json || []).forEach((item) => {
+ const code = normalizeProductCode(item.codigo);
+ const fardosPorPalete = getFardosPorPalete(code);
+ conferenciaForm.insertAdjacentHTML('beforeend', ``);
+ });
+
+ conferenciaForm.insertAdjacentHTML('beforeend', 'Observação Enviar conferência ');
+ 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
' : `Fechar descarga e enviar para logs `}
+ Reabrir conferência `;
+ 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}Copiar linha `;
+ 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 = `
+
+
+
+ DT
+ Placa
+ Motorista
+ NF
+ Início carga
+ Término
+ PL2
+ Paletes
+
+
+ ${rows}
+
+ `;
+}
+
+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 ``;
+ }).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;