▦ ◷ 🟢 Online♧ 0
AD
Admin
Administrador

Olá, Admin!

✦ Gestão Inteligente de Mercadorias
Mais controlo,
mais vendas!
▣
0↟ Inventário sob controlo
🛒
MT 0,00↗ Valor total vendido
◇
0↗ Unidades vendidas
▤
MT 0,00Produtos disponíveis
▦
0Unidades actualmente em stock

Valor dos Movimentos — Últimos 7 Dias Volume (MT)

● Compras · Vendas · Consumo · Despesas · Ajustes · Últimos 7 dias

Método de Valorização do Stock ⓘ

🟢 FIFO 60%🔵 LIFO 20%🟠 Custo Médio 20%

🔔 Alertas de Mercadorias 0 alertas

▦ Calendário Inteligente

▤ Movimentos Recentes

Data e HoraTipoProdutoQuantidadeValor (MT)
Ainda não existem movimentos.

⚡ Acções Rápidas

◉ StockFácil Moçambique · 2026 · v5.0Gestão prática, resultados reais · Gestão simples. Resultados melhores.
`); d.close();setTimeout(()=>{try{f.contentWindow.focus();f.contentWindow.print()}catch(e){alert('Não foi possível abrir a impressão. Use Ctrl+P no documento aberto.')}setTimeout(()=>f.remove(),15000)},500); } function eanCheckGlobal(digits){if(!/^\d{13}$/.test(String(digits||'')))return false;const sum=String(digits).slice(0,12).split('').reduce((a,d,i)=>a+Number(d)*(i%2?3:1),0);return (10-sum%10)%10===Number(String(digits)[12])} function generateEANGlobal(){let code;do{const base='20'+String(Math.floor(Math.random()*10000000000)).padStart(10,'0');const first=base.slice(0,12),sum=first.split('').reduce((a,d,i)=>a+Number(d)*(i%2?3:1),0);code=first+String((10-sum%10)%10)}while(db.products.some(p=>p.barcode===code));return code} function printLabels(p,count){ const win=window.open('','_blank','width=900,height=700');if(!win)return alert('O navegador bloqueou a janela de impressão. Permita pop-ups para este ficheiro e tente novamente.'); const company=esc(db.settings.business||'StockFácil Moçambique'),items=Array.from({length:count},(_,i)=>`
◆ ${company}
${esc(p.name)}
${esc(money(p.price))}
`).join(''); win.document.open();win.document.write(`Etiquetas — ${esc(p.name)} `);win.document.close(); } function resetIdle(){ clearTimeout(idleT);const m=db.settings.idle;if(!activeId||!m)return; idleT=setTimeout(()=>logout('Sessão terminada por inactividade.'),m*60000); } /* ---------- Atribuir senha a outro utilizador (administrador) ---------- */ function genPass(n=12){ const A='ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789',L=A.length,lim=256-256%L;let o=''; while(o.lengthUtilizador: ${esc(u.username)}. A nova senha substitui a actual e deve ser usada no próximo início de sessão. Comunique-a ao utilizador por um canal seguro.

`,``); const err=t=>el('pwErr').textContent=t||'',show=on=>{el('pwShow').checked=on;['pwNew','pwNew2'].forEach(i=>el(i).type=on?'text':'password')}; el('pwShow').onchange=e=>show(e.target.checked); el('pwGen').onclick=()=>{const g=genPass();el('pwNew').value=g;el('pwNew2').value=g;show(true);err('')}; setTimeout(()=>{const a=document.activeElement;if(!(a&&a.tagName==='INPUT'&&a.closest('#sfModalCard')))el('pwNew')?.focus()},50); el('pwSave').onclick=async()=>{ const n=el('pwNew').value,c=el('pwNew2').value,a=el('pwAdmin').value; if(n.length<8)return err('A senha deve ter pelo menos 8 caracteres.'); if(n!==c)return err('As senhas não coincidem.'); if(!a)return err('Introduza a sua senha de administrador para confirmar.'); const key='adminpw:'+me().username.toLowerCase(),w=lockWait(key); if(w)return err(`Demasiadas tentativas. Aguarde ${w}s.`); const b=el('pwSave');b.disabled=true; try{ if(!await check(a,me().pw)){regFail(key);return err('A sua senha de administrador está incorrecta.')} clrFail(key); const t=db.users.find(x=>x.id===u.id);if(!t)return err('Utilizador não encontrado.'); const snap=JSON.stringify(db); t.pw=await mk(n);t.mustChange=el('pwMust').checked; if(!save()){db=normalizeDb(JSON.parse(snap));return} clrFail('login:'+t.username.toLowerCase()); closeModal();renderSection(currentPage);notify('Senha de “'+t.username+'” atribuída.'); }finally{b.disabled=false} }; } /* ---------- Painel flutuante de detalhes do produto ---------- */ function selectProduct(id){selId=id;renderDetail()} function closeDetail(){selId=null;renderDetail()} function renderDetail(){ const box=$('sfDetail');if(!box)return; const p=selId&&me()?db.products.find(x=>x.id===selId):null; const mark=()=>document.querySelectorAll('#sectionView tr[data-pid]').forEach(r=>r.classList.toggle('sel',!!p&&r.dataset.pid===p.id)); if(!p||!['Produtos','Alertas'].includes(currentPage)){ selId=null;box.classList.remove('open');document.body.classList.remove('has-detail');mark();return; } const sc=box.querySelector('.sfd-body')?.scrollTop||0,wasOpen=box.classList.contains('open'); const mv=db.moves.filter(m=>m.productId===p.id),sold=mv.filter(m=>m.type==='Venda'); const units=sold.reduce((a,m)=>a+Math.abs(m.qty),0),rev=sold.reduce((a,m)=>a+m.total,0); const costs=can('see_costs'),val=costs?valueOf(p,db.settings.method,movesByProduct().get(p.id)):0; const margin=p.price-p.cost,mpct=p.price>0?(margin/p.price*100).toFixed(1).replace('.',','):null; const state=p.qty<=0?'Esgotado':p.qty<=p.min?'Stock baixo':'Em stock'; let ex='—';if(p.expiry){const d=daysTo(p.expiry);ex=fmtDate(p.expiry)+' · '+(d<0?`vencido há ${-d} dia${d===-1?'':'s'}`:d===0?'vence hoje':`faltam ${d} dia${d===1?'':'s'}`)} const last=mv.slice(0,5).map(m=>`
${esc(m.type)}${fmtDT(m.date)}${m.qty>0?'+':''}${m.qty}
`).join('')||'

Sem movimentos registados.

'; box.innerHTML=`
📦
${esc(p.name)}${esc(p.category)}
${state}
Em stock${p.qty}Mínimo: ${p.min}
Preço de venda${money(p.price)}${costs?`Margem: ${money(margin)}${mpct!==null?` (${mpct}%)`:''}`:''}
${costs?`
Custo unitário${money(p.cost)}
Valor em stock${money(val)}${esc(METHODS[db.settings.method])}
`:''}
Código / SKU
${esc(p.sku)||'—'}
Validade
${ex}
Unidades vendidas
${units.toLocaleString('pt-MZ')}
Total vendido
${money(rev)}

Últimos movimentos

${last}
${can('edit_product')?'':''}${can('make_sale')?``:''}${can('make_purchase')?'':''}
`; $('sfdClose').onclick=closeDetail; if($('sfdEdit'))$('sfdEdit').onclick=()=>productForm(p); if($('sfdSell'))$('sfdSell').onclick=()=>movementForm('Venda',p.id); if($('sfdBuy'))$('sfdBuy').onclick=()=>movementForm('Compra',p.id); const body=box.querySelector('.sfd-body');if(wasOpen)body.scrollTop=sc; box.classList.add('open');document.body.classList.add('has-detail');mark(); } /* ---------- Formulários ---------- */ function productForm(p){ p=p||{}; if(p.id?!can('edit_product'):!can('add_product'))return notify('Não tem permissão para esta acção.'); modal(p.id?'Editar produto':'Adicionar produto',`
O código automático usa EAN-13 interno. Códigos comerciais oficiais devem ser atribuídos pela GS1.
`,``); const barcodeInput=el('pfBarcode'),barcodeSvg=el('pfBarcodePreview'); const eanCheck=digits=>{if(!/^\d{13}$/.test(digits))return false;const sum=digits.slice(0,12).split('').reduce((a,d,i)=>a+Number(d)*(i%2?3:1),0);return (10-sum%10)%10===Number(digits[12])}; const newEAN=()=>{let code='';do{const prefix='20'+String(Math.floor(Math.random()*10000000000)).padStart(10,'0');const base=prefix.slice(0,12);const sum=base.split('').reduce((a,d,i)=>a+Number(d)*(i%2?3:1),0);code=base+String((10-sum%10)%10)}while(db.products.some(x=>x.id!==p.id&&x.barcode===code));return code}; const drawBarcode=()=>{if(!barcodeSvg||!barcodeInput)return;const v=barcodeInput.value.trim();barcodeSvg.innerHTML='';if(!v)return;try{if(window.JsBarcode){if(/^\d{13}$/.test(v)&&eanCheck(v)||/^\d{12}$/.test(v)||/^\d{8}$/.test(v)||/^[A-Za-z0-9._-]{4,30}$/.test(v))JsBarcode(barcodeSvg,v,{format:/^\d{13}$/.test(v)?'EAN13':/^\d{12}$/.test(v)?'UPC':/^\d{8}$/.test(v)?'EAN8':'CODE128',displayValue:true,lineColor:'#111',width:2,height:48,margin:5});else if(/^\d{12}$/.test(v))JsBarcode(barcodeSvg,v,{format:'EAN13',displayValue:true,lineColor:'#111',width:2,height:48,margin:5});else barcodeSvg.innerHTML='Introduza um EAN-13 válido ou gere um código.'}else barcodeSvg.innerHTML='A pré-visualização requer ligação à internet.'}catch(e){barcodeSvg.innerHTML='Código inválido para EAN-13.'}}; barcodeInput?.addEventListener('input',drawBarcode);el('pfGenerateBarcode').onclick=()=>{barcodeInput.value=newEAN();drawBarcode()};if(!barcodeInput.value.trim())barcodeInput.value=newEAN();drawBarcode(); el('sfSaveProduct').onclick=()=>{ const name=el('pfName').value.trim(),sku=el('pfSku').value.trim();let barcode=el('pfBarcode')?.value.trim()||''; if(!name)return alert('Indique o nome do produto.'); if(!/^[A-Za-z0-9._-]{4,30}$/.test(barcode)||(/^\d{13}$/.test(barcode)&&!eanCheck(barcode)))return alert('Código de barras inválido (4–30 caracteres; EAN-13 com dígito de controlo correcto).'); if(sku&&db.products.some(x=>x.id!==p.id&&x.sku&&x.sku.toLowerCase()===sku.toLowerCase()))return alert('Já existe um produto com esse código/SKU.'); if(barcode&&db.products.some(x=>x.id!==p.id&&x.barcode&&x.barcode.toLowerCase()===barcode.toLowerCase()))return alert('Já existe um produto com esse código de barras.'); if(db.products.some(x=>x.id!==p.id&&norm(x.name)===norm(name)&&(!sku||!x.sku)))return alert('Já existe um produto com esse nome.'); const n=v=>Math.max(0,Number(v)||0); const obj={id:p.id||uid(),name,sku,barcode,category:el('pfCat').value.trim()||'Geral',qty:Math.floor(n(el('pfQty').value)),cost:r2(n(el('pfCost').value)),price:r2(n(el('pfPrice').value)),min:Math.floor(n(el('pfMin').value)),expiry:validDate(el('pfExpiry').value)}; const snap=JSON.stringify(db); if(p.id){ const ix=db.products.findIndex(x=>x.id===p.id);if(ix<0)return alert('Produto não encontrado.'); const old=db.products[ix];obj.lots=Array.isArray(old.lots)?JSON.parse(JSON.stringify(old.lots)):[]; let lotQty=obj.lots.reduce((a,l)=>a+Math.max(0,Number(l.qty)||0),0); if(lotQtyold.qty)obj.lots.push({id:uid(),qty:obj.qty-old.qty,cost:obj.cost,receivedAt:new Date().toISOString(),expiry:obj.expiry||''}); else if(obj.qtyString(b.receivedAt).localeCompare(String(a.receivedAt))):[...obj.lots].sort((a,b)=>String(a.receivedAt).localeCompare(String(b.receivedAt)));for(const l of seq){if(remove<=0)break;const take=Math.min(remove,l.qty);l.qty-=take;remove-=take;}obj.lots=obj.lots.filter(l=>l.qty>0)} obj.expiry=obj.expiry||obj.lots.map(l=>l.expiry).filter(Boolean).sort()[0]||''; db.products[ix]=obj; if(old.price!==obj.price)audit('Alteração de preço',`${old.name}: ${old.price} MT → ${obj.price} MT`); if(old.cost!==obj.cost)audit('Alteração de custo',`${old.name}: ${old.cost} MT → ${obj.cost} MT`); if(old.qty!==obj.qty)audit('Ajuste de stock',`${old.name}: ${old.qty} → ${obj.qty} unidades`); if(old.name!==obj.name)db.moves.forEach(m=>{if(m.productId===obj.id)m.product=obj.name}); if(obj.qty!==old.qty)addMove('Ajuste',obj,obj.qty-old.qty,obj.cost); }else{obj.lots=obj.qty>0?[{id:uid(),qty:obj.qty,cost:obj.cost,receivedAt:new Date().toISOString(),expiry:obj.expiry||''}]:[];db.products.push(obj);audit('Criação de produto',`${obj.name} (SKU ${obj.sku||'—'})`);if(obj.qty>0)addMove('Entrada inicial',obj,obj.qty,obj.cost)} if(!save()){db=normalizeDb(JSON.parse(snap));return} closeModal();renderSection('Produtos');notify('Produto guardado.'); }; } function movementForm(type,preId){ const isSale=type==='Venda',isPurchase=type==='Compra',isTransfer=['Movimentação de Mercadoria','Ajuste'].includes(type),isStockOut=()=>isSale||['Consumo Interno','Outras Despesas de Funcionamento / Consumo'].includes(type)||(isTransfer&&(el('mfDirection')?.value||'Saída')==='Saída'); if(type==='Venda'?!can('make_sale'):type==='Compra'?!can('make_purchase'):!(isAdmin()||can('make_purchase')))return notify('Não tem permissão para esta acção.'); if(!db.products.length)return alert('Adicione primeiro um produto.'); const opts=db.products.map(p=>``).join(''); modal('Registar '+type.toLowerCase(),`
Leitor USB/Bluetooth (funciona como teclado): leia o código; Enter adiciona o artigo ou aumenta a quantidade. Compatível com leitores que enviam Enter/Tab.
${isTransfer?`

Uma transferência completa entre armazéns requer registo dos dois locais. Aqui regista-se o lado da localização actual.

`:''}
Produtos da operação
${isPurchase?'':''}
ProdutoCategoriaQtd.${isSale?'Preço unit.':'Valor unit.'} (MT)Validade do loteSubtotal

Seleccione os produtos e indique as quantidades. Não é permitido repetir o mesmo produto em linhas diferentes.

Total da operação

MT 0,00

${isPurchase?`
`:''}${isSale?`

Contacto/reclamações: ${esc(db.settings.contactPhone||'não configurado')} · WhatsApp: ${esc(db.settings.contactWhatsApp||'não configurado')} · E-mail: ${esc(db.settings.contactEmail||'não configurado')}. O registo do método não confirma automaticamente pagamentos móveis.

`:''} `,``); const lines=el('mfLines'); function addLine(pid='',qty=1,price=null){ const row=document.createElement('tr');row.className='mf-line'; row.innerHTML=`${esc(db.products.find(x=>x.id===pid)?.category||'—')}${isPurchase?``:''}MT 0,00`; lines.appendChild(row);const sel=row.querySelector('.mf-product');if(pid)sel.value=pid; const setPrice=()=>{const p=db.products.find(x=>x.id===sel.value);if(p&&price===null)row.querySelector('.mf-price').value=isSale?p.price:p.cost;updateTotal()}; sel.addEventListener('change',()=>{const p=db.products.find(x=>x.id===sel.value);if(p){row.querySelector('.mf-price').value=isSale?p.price:p.cost;row.querySelector('.mf-category').textContent=p.category||'Geral'}else row.querySelector('.mf-category').textContent='—';updateTotal()}); row.querySelectorAll('input').forEach(i=>i.addEventListener('input',updateTotal)); row.querySelector('.mf-remove').onclick=()=>{if(lines.querySelectorAll('.mf-line').length<=1)return alert('Mantenha pelo menos uma linha de produto.');row.remove();updateTotal()}; setPrice();updateTotal();return row; } function updateTotal(){ let total=0;lines.querySelectorAll('.mf-line').forEach(row=>{const q=Number(row.querySelector('.mf-qty').value)||0,p=Number(row.querySelector('.mf-price').value)||0,sub=q*p;total+=sub;row.querySelector('.mf-subtotal').textContent=money(sub)}); el('mfTotal').textContent=money(total); if(isSale&&el('mfPaid')){const paid=Number(el('mfPaid').value)||0;el('mfChange').value=money(Math.max(0,paid-total));if(el('mfOutstanding'))el('mfOutstanding').value=money(el('mfPayment')?.value==='Dívida / Venda a crédito'?Math.max(0,total-paid):0);el('mfChange').title=paid{ const term=norm(el('mfSearch')?.value||''),category=el('mfCategory')?.value||''; lines.querySelectorAll('.mf-product').forEach(sel=>{ const selected=sel.value;const filtered=db.products.filter(p=>(!category||(p.category||'Geral').trim()===category)&&(!term||norm([p.name,p.category,p.sku,p.barcode].filter(Boolean).join(' ')).includes(term))); sel.innerHTML=''+filtered.map(p=>``).join(''); if(selected&&db.products.some(p=>p.id===selected)){const p=db.products.find(p=>p.id===selected);if(!filtered.some(x=>x.id===selected))sel.insertAdjacentHTML('beforeend',``);sel.value=selected} }); }; addLine(preId||'');el('mfSearch')?.addEventListener('input',searchProducts);el('mfCategory')?.addEventListener('change',searchProducts); if(isSale&&el('mfPaid'))el('mfPaid').addEventListener('input',updateTotal);if(isSale)el('mfPayment')?.addEventListener('change',()=>{const debt=el('mfPayment').value==='Dívida / Venda a crédito';if(debt&&el('mfPaid')&&Number(el('mfPaid').value)===0)el('mfPaid').value='0';updateTotal();}); el('mfAdd').onclick=()=>addLine(); const pick=code=>{ code=String(code||'').trim();if(!code)return; const codeKey=code.replace(/[\\s-]+/g,'').toLocaleLowerCase(); const p=db.products.find(x=>[x.sku,x.barcode].some(v=>v&&String(v).trim().replace(/[\\s-]+/g,'').toLocaleLowerCase()===codeKey)); if(!p){el('mfHint').textContent='Código “'+code+'” não encontrado.';el('mfCode').value='';return} let row=[...lines.querySelectorAll('.mf-line')].find(r=>r.querySelector('.mf-product').value===p.id); if(row){row.querySelector('.mf-qty').value=(Number(row.querySelector('.mf-qty').value)||0)+1} else {const empty=[...lines.querySelectorAll('.mf-line')].find(r=>!r.querySelector('.mf-product').value);if(empty){empty.querySelector('.mf-product').value=p.id;empty.querySelector('.mf-product').dispatchEvent(new Event('change',{bubbles:true}));row=empty}else{row=addLine(p.id,1,isSale?p.price:p.cost)}} row.querySelector('.mf-product').value=p.id;row.querySelector('.mf-price').value=isSale?p.price:p.cost; el('mfCode').value='';el('mfHint').textContent=p.name+' adicionado à operação.';updateTotal(); }; el('mfCode').addEventListener('keydown',e=>{if(e.key==='Enter'){e.preventDefault();pick(el('mfCode').value);el('mfCode').focus()}});setTimeout(()=>el('mfCode')?.focus(),80); el('mfScan').onclick=()=>startProductScanner(c=>pick(c),reason=>{if(reason)alert(reason);el('mfCode')?.focus()}); el('sfSaveMove').onclick=()=>{ const items=[],seen=new Set(); {const rs=[...lines.querySelectorAll('.mf-line')];if(rs.some(r=>r.querySelector('.mf-product').value))rs.filter(r=>!r.querySelector('.mf-product').value).forEach(r=>r.remove())} for(const row of lines.querySelectorAll('.mf-line')){ const id=row.querySelector('.mf-product').value,p=db.products.find(x=>x.id===id),q=Number(row.querySelector('.mf-qty').value),price=Number(row.querySelector('.mf-price').value); if(!p)return alert('Seleccione um produto em todas as linhas.'); if(seen.has(id))return alert('O produto "'+p.name+'" está repetido. Junte as quantidades numa única linha.'); seen.add(id); if(!Number.isInteger(q)||q<1||!Number.isFinite(price)||price<0)return alert('Verifique as quantidades e os preços. As quantidades devem ser números inteiros positivos.'); const expiry=isPurchase?(row.querySelector('.mf-expiry')?.value||''):'';if(expiry&&!validDate(expiry))return alert('Indique uma data de validade válida para o lote de '+p.name+'.');items.push({p,q,price,expiry}); } if(!items.length)return alert('Adicione pelo menos um produto.'); if(isStockOut()){const shortage=items.find(x=>x.q>x.p.qty);if(shortage)return alert('Stock insuficiente para '+shortage.p.name+'. Disponível: '+shortage.p.qty+'; solicitado: '+shortage.q+'. Nenhuma alteração foi feita.')} const debtState=isPurchase?(el('mfDebt')?.value||'Pago'):'',creditor=isPurchase?(el('mfCreditor')?.value.trim()||''):'',saleMethod=isSale?(el('mfPayment')?.value||'M-Pesa'):'',customer=isSale?(el('mfCustomer')?.value.trim()||''):'',saleDebt=isSale&&saleMethod==='Dívida / Venda a crédito',destination=isTransfer?(el('mfDestination')?.value.trim()||''):'';if(isPurchase&&debtState==='Dívida'&&!creditor)return alert('Indique o nome do fornecedor/credor para registar a dívida.');if(saleDebt&&!customer)return alert('Indique o nome do cliente que ficará a dever.');const totalBeforeSave=items.reduce((a,x)=>a+x.q*x.price,0),paidAmount=isSale?(Number(el('mfPaid')?.value)||0):0;if(isSale&&!saleDebt&&paidAmount>0&&paidAmount0)x.p.lots.push({id:uid(),qty:x.p.qty,cost:r2(x.p.cost),receivedAt:new Date(0).toISOString(),expiry:x.p.expiry||''}); let lotTotal=x.p.lots.reduce((a,l)=>a+Math.max(0,Number(l.qty)||0),0);if(lotTotalx.p.qty){let excess=lotTotal-x.p.qty;for(let i=x.p.lots.length-1;i>=0&&excess>0;i--){const take=Math.min(excess,x.p.lots[i].qty);x.p.lots[i].qty-=take;excess-=take}x.p.lots=x.p.lots.filter(l=>l.qty>0)} if(isStockOut()){ let need=x.q;const lots=[...x.p.lots].sort((a,b)=>String(a.receivedAt).localeCompare(String(b.receivedAt)));const sequence=db.settings.method==='LIFO'?[...lots].reverse():lots; if(db.settings.method==='CMP'){const avg=x.p.qty>0?x.p.lots.reduce((a,l)=>a+l.qty*l.cost,0)/x.p.qty:x.p.cost;let remain=need;for(const l of sequence){const take=Math.min(remain,l.qty);l.qty-=take;remain-=take;if(remain<=0)break}x.p.cost=r2(avg)} else for(const l of sequence){if(need<=0)break;const take=Math.min(need,l.qty);l.qty-=take;need-=take} x.p.lots=x.p.lots.filter(l=>l.qty>0);x.p.qty-=x.q;const activeExpiry=x.p.lots.map(l=>l.expiry).filter(Boolean).sort();x.p.expiry=activeExpiry[0]||''; }else if(isPurchase){ x.p.qty+=x.q;x.p.cost=r2(x.price);x.p.lots.push({id:uid(),qty:x.q,cost:r2(x.price),receivedAt:new Date().toISOString(),expiry:x.expiry||''});x.p.lots.sort((a,b)=>String(a.receivedAt).localeCompare(String(b.receivedAt)));const lotExp=x.p.lots.map(l=>l.expiry).filter(Boolean).sort();if(lotExp.length)x.p.expiry=lotExp[0]; }else if(isTransfer){ const dir=el('mfDirection')?.value||'Saída';if(dir==='Entrada'){x.p.qty+=x.q;x.p.lots.push({id:uid(),qty:x.q,cost:r2(x.price),receivedAt:new Date().toISOString(),expiry:''});}else{x.p.qty-=x.q;} } const signedQty=(isStockOut()?-x.q:(isTransfer&&((el('mfDirection')?.value||'Saída')==='Saída')?-x.q:x.q));const mv=addMove(type,x.p,signedQty,x.price);if(destination)mv.destination=destination;if(isPurchase){mv.paymentStatus=debtState;mv.creditor=creditor;mv.invoiceNote=debtState==='Dívida'?'COMPRA A CRÉDITO — DEVE-SE A: '+creditor:'';}if(isSale){mv.payment=payment;mv.paymentStatus=saleDebt?'Dívida':'Pago';mv.customer=customer;mv.outstanding=saleDebt?Math.max(0,r2(x.q*x.price-(totalBeforeSave>0?payment.paid*(x.q*x.price/totalBeforeSave):0))):0;mv.invoiceNote=saleDebt?'VENDA A CRÉDITO — CLIENTE DEVEDOR: '+customer+' — SALDO: '+money(mv.outstanding):'';const j=db.ledger.filter(z=>z.moveId===mv.id&&z.account==='Caixa');if(j.length&&saleDebt)j.forEach(z=>z.account='Contas a receber — Clientes');else if(j.length&&payment.method!=='Dinheiro')j.forEach(z=>z.account=payment.method==='M-Pesa'?'M-Pesa a receber':payment.method==='e-Mola'?'e-Mola a receber':'Banco/BCI a receber');}audit(type,`${x.p.name}: ${x.q} unidade(s), total ${money(x.q*x.price)}${isSale?'; pagamento '+payment.method+'; referência '+(payment.reference||'—'):''}`);saved.push({...mv}); } if(!save()){db=normalizeDb(JSON.parse(snap));return} closeModal(); const pg=isSale?'Vendas':isPurchase?'Compras':type==='Consumo Interno'?'Consumo Interno':type==='Outras Despesas de Funcionamento / Consumo'?'Outras Despesas':'Movimentação'; if(preId)renderSection(currentPage);else if(PAGE_PERM[pg]&&can(PAGE_PERM[pg]))renderSection(pg);else go('Painel Principal'); if(isSale||isPurchase){const grand=items.reduce((a,x)=>a+x.q*x.price,0);if(confirm((isSale?'Venda':'Compra')+' registada. Total '+money(grand)+(isPurchase&&debtState==='Dívida'?' — Dívida a: '+creditor:'')+'. Imprimir documento?'))printReceipt({seq:saved[0].seq,date:saved[0].date,by:saved[0].by,items:saved.map(m=>({product:m.product,qty:Math.abs(m.qty),price:m.price,total:m.total})),total:saved.reduce((a,m)=>a+m.total,0),note:isPurchase&&debtState==='Dívida'?'COMPRA A CRÉDITO — DEVE-SE A: '+creditor:isSale&&saleDebt?'VENDA A CRÉDITO — CLIENTE DEVEDOR: '+customer+' — SALDO: '+money(Math.max(0,grand-payment.paid)):''})} else notify(type+' registado com '+items.length+' produto(s). Total: '+money(items.reduce((a,x)=>a+x.q*x.price,0))); }; } function userForm(u){ u=u||{}; const curPerms=u.id?(u.perms||{}):Object.fromEntries(PRESETS.vendedor.keys.map(k=>[k,true])); modal(u.id?'Editar utilizador':'Criar utilizador',`
${permsHTML(curPerms)}
`,``); const box=el('ufPerms'),sync=()=>{box.style.display=el('ufRole').value==='admin'?'none':''}; el('ufRole').onchange=sync;sync();if(!u.id)el('ufPreset').value='vendedor'; el('ufPreset').onchange=e=>{const p=PRESETS[e.target.value];if(!p)return;box.querySelectorAll('[data-perm]').forEach(c=>c.checked=p.keys.includes(c.dataset.perm))}; el('saveUser').onclick=async()=>{ const name=el('ufName').value.trim(),username=el('ufUser').value.trim(),password=el('ufPass').value,role=el('ufRole').value,method=el('ufMethod').value,prompt=el('ufPrompt').value.trim(),rec=el('ufRecovery').value.trim(),disabled=el('ufState').value==='off',mc=el('ufMust').checked; let e=''; if(!name)e='Indique o nome completo.'; else if(!UNAME.test(username)&&username!==u.username)e='O nome de utilizador deve ter 3 a 30 caracteres (letras, números, ponto, hífen ou sublinhado).'; else if(!username)e='Indique o nome de utilizador.'; else if(!u.id&&password.length<8)e='A senha deve ter pelo menos 8 caracteres.'; else if(password&&password.length<8)e='A nova senha deve ter pelo menos 8 caracteres.'; else if(db.users.some(x=>x.username.toLowerCase()===username.toLowerCase()&&x.id!==u.id))e='Esse nome de utilizador já existe.'; const changedRec=!u.id||rec||method!==u.recoveryMethod; if(!e&&changedRec)e=validateRecovery(method,rec,prompt); if(!e&&method==='question'&&!changedRec&&!prompt)e='Indique a pergunta secreta.'; if(!e&&u.id&&u.id===activeId&&disabled)e='Não pode desactivar a sua própria conta.'; if(!e&&u.id&&u.role==='admin'&&(role!=='admin'||disabled)&&!db.users.some(x=>x.role==='admin'&&!x.disabled&&x.id!==u.id))e='Tem de existir pelo menos um administrador activo.'; if(e)return alert(e); const b=el('saveUser');b.disabled=true; const snap=JSON.stringify(db); try{ const obj={id:u.id||uid(),name,username,role,pw:u.pw,rec:u.rec,recoveryMethod:method,recoveryPrompt:method==='question'?prompt:'',created:u.created||new Date().toISOString(),email:method==='email'?(rec?rec.toLowerCase():(u.email||'')):'',disabled,mustChange:password?mc:!!u.mustChange}; if(role!=='admin'){obj.perms={};PERM_KEYS.forEach(k=>obj.perms[k]=!!box.querySelector(`[data-perm="${k}"]`).checked)} if(password)obj.pw=await mk(password); if(changedRec)obj.rec=await mk(recNorm(method,rec)); if(u.id){const i=db.users.findIndex(x=>x.id===u.id);if(i<0)return alert('Utilizador não encontrado.');db.users[i]=obj;audit('Alteração de utilizador',`${obj.username}: perfil/permissões/estado actualizados`)}else{db.users.push(obj);audit('Criação de utilizador',`${obj.username} (${obj.role})`)} if(!save()){db=normalizeDb(JSON.parse(snap));return} if(obj.id===activeId)applyUserUI(); closeModal();renderSection(currentPage==='Definições'?'Definições':'Utilizadores');notify('Utilizador configurado.'); }finally{b.disabled=false} }; } function showTerms(mandatory=false){ const body=`

1. Identificação e âmbito

O StockFácil Moçambique é uma ferramenta de apoio à gestão de mercadorias, inventário, compras, vendas e relatórios. O utilizador deve confirmar que os dados registados são exactos e conservar os documentos comerciais e fiscais exigidos à sua actividade.

2. Uso responsável

É proibido utilizar o sistema para fraude, falsificação de registos, acesso não autorizado ou qualquer finalidade contrária à lei. Cada utilizador deve usar a sua própria conta e proteger as credenciais.

3. Propriedade dos dados e confidencialidade

Os registos comerciais introduzidos pertencem à empresa utilizadora. Nesta versão autónoma, os dados são guardados no navegador/dispositivo local e não são enviados automaticamente para o criador da aplicação. O fornecedor não dispõe, através deste ficheiro local, de um painel remoto para consultar facturas, clientes, preços, custos ou transacções individuais. Quem tiver acesso ao dispositivo, ao perfil do navegador ou às cópias exportadas poderá, contudo, consultar os dados; por isso, proteja o equipamento e limite as permissões de acesso.

Se no futuro a empresa activar serviços de nuvem, integrações externas ou sincronização entre dispositivos, esses serviços deverão ser avaliados separadamente quanto às permissões, ao tratamento dos dados e às condições aplicáveis antes de serem utilizados.

4. Senhas e cópias de segurança

As senhas são guardadas sob a forma de hash PBKDF2-SHA-256 com salt individual. Isto reduz a exposição, mas não garante segurança absoluta. Faça cópias de segurança regulares, guarde-as em local protegido e teste a restauração. A cópia automática local não substitui uma cópia externa.

5. Stock, lotes e valorização

O FIFO utiliza primeiro os lotes mais antigos; LIFO utiliza os mais recentes; Custo Médio usa média ponderada. O utilizador deve verificar quantidades, custos, validades e ajustes antes de fechar períodos.

6. Conformidade em Moçambique

A empresa utilizadora é responsável por cumprir a legislação moçambicana aplicável, incluindo regras comerciais, fiscais, de facturação e de protecção de dados pessoais, nomeadamente a Lei das Transacções Electrónicas (Lei n.º 3/2017) e demais normas que sejam aplicáveis ao caso concreto. Este texto é um modelo informativo, não constitui parecer jurídico nem certifica o sistema como software de facturação fiscal. Antes de utilização comercial definitiva, a empresa deve solicitar revisão jurídica e fiscal actualizada e confirmar os requisitos da Autoridade Tributária de Moçambique.

7. Controlo e portabilidade

A empresa pode exportar os seus dados para cópia de segurança e deve guardá-los num local protegido. A cópia automática local não substitui uma cópia externa testada. Antes de limpar dados do navegador, reinstalar o sistema ou mudar de computador, exporte e verifique uma cópia.

8. Aceitação e alterações

O utilizador confirma que tem autorização para usar o sistema e que compreende os limites da versão local. A aceitação fica registada neste navegador, com versão e data. Se o armazenamento do navegador for apagado ou mudar de dispositivo, poderá ser necessário aceitar novamente. Este texto é informativo e deve ser revisto por profissional jurídico/fiscal para utilização comercial definitiva em Moçambique.

Versão: 4.3 · Revisão: 19/10/2026

${mandatory?'':'

Para alterar as condições aceites, consulte o administrador da empresa.

'}`; const buttons=mandatory?'':''; modal('Termos e Condições de Utilização',body,buttons); const close=$('sfClose');if(mandatory&&close)close.style.display='none'; if(mandatory){const c=$('termsCheck'),b=$('termsAccept');c.onchange=()=>b.disabled=!c.checked;b.onclick=()=>{if(!c.checked)return;try{localStorage.setItem('stockflow_terms_v4_1_accepted','yes');localStorage.setItem('stockflow_terms_v4_1_audit',JSON.stringify({version:'4.0',acceptedAt:new Date().toISOString(),browser:navigator.userAgent,scope:'este navegador/dispositivo'}))}catch(e){return alert('Não foi possível guardar a aceitação. Active o armazenamento local do navegador para continuar.')}closeModal();continueStartup()};} else if($('termsAccept'))$('termsAccept').onclick=closeModal; } function continueStartup(){const sid=sget(),u=sid&&db.users.find(x=>x.id===sid);if(u&&!u.disabled&&!u.mustChange)startSession(u);else authShow(db.users.length?'login':'setup');} function applyTheme(t){const c=THEMES[t]||THEMES.verde,r=document.documentElement;r.style.setProperty('--green',c[0]);r.style.setProperty('--bg',c[1]);r.style.setProperty('--navy',c[2]);document.body.classList.toggle('dark',t==='escuro')} function applyBusiness(){const b=db.settings.business;$('bizName').textContent=b;document.title=b+' — Painel Inteligente';updateSideContacts()} function updateSideContacts(){ const s=db.settings||{},phone=String(s.contactPhone||'').trim(),wa=String(s.contactWhatsApp||'').trim(),email=String(s.contactEmail||'').trim(); const setLink=(id,href,label,display)=>{const a=$(id);if(!a)return;if(href){a.href=href;a.style.opacity='1';a.removeAttribute('aria-disabled')}else{a.href='#';a.style.opacity='.55';a.setAttribute('aria-disabled','true')}if(label)a.title=label;if(display)a.textContent=display}; const digits=v=>v.replace(/\D/g,'');setLink('sideWhatsApp',digits(wa)?'https://wa.me/'+digits(wa):'','WhatsApp oficial',wa?'☏ WhatsApp · '+wa:'☏ WhatsApp · não configurado');setLink('sidePhone',digits(phone)?'tel:'+phone.replace(/[^+0-9]/g,''):'','Chamadas e reclamações',phone?'☎ Chamadas · '+phone:'☎ Chamadas · não configurado');setLink('sideEmail',/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)?'mailto:'+email:'','E-mail de apoio',email?'✉ '+email:'✉ E-mail · não configurado'); setLink('sideFacebook',safeSocial(s.socialFacebook),'Facebook oficial');setLink('sideLinkedIn',safeSocial(s.socialLinkedIn),'LinkedIn oficial');setLink('sideInstagram',safeSocial(s.socialInstagram),'Instagram oficial'); } function safeSocial(v){try{const u=new URL(String(v||''));return ['https:'].includes(u.protocol)?u.href:''}catch(e){return ''}} /* ---------- Importação de listas ---------- */ function classifyHeader(h){ const k=normKey(h);if(!k)return ''; if(/minim|^min|stockmin|qtdmin/.test(k))return 'min'; if(/valid|expir|vencim|caduc/.test(k))return 'expiry'; if(/custo|cost|compra|aquisi/.test(k))return 'cost'; if(/venda|preco|price|pvp|tarifa|^valor(unit)?/.test(k)&&!/total/.test(k))return 'price'; if(/barcode|ean|barras|codigo de barras/.test(k))return 'barcode'; if(/codigo|^cod|sku|refer/.test(k))return 'sku'; if(/categ|grupo|famil/.test(k))return 'category'; if(/quant|stock|estoque|qtd|qty|exist|saldo|unidades/.test(k))return 'qty'; if(/nome|produto|name|product|descri|design|artigo|mercadoria|item/.test(k))return 'name'; return ''; } function mapProductRow(r){ const found={}; for(const[k,v]of Object.entries(r||{})){const f=classifyHeader(k.replace(/__\d+$/,''));if(!f)continue;if(v===undefined||v===null||String(v).trim()==='')continue;if(found[f]===undefined)found[f]=v} const num=v=>{if(typeof v==='number')return Number.isFinite(v)?v:0;let s=String(v??'').replace(/[^\d,.\-]/g,'');if(!s)return 0; if(s.includes(',')&&s.includes('.'))s=s.lastIndexOf(',')>s.lastIndexOf('.')?s.replace(/\./g,'').replace(',','.'):s.replace(/,/g,''); else if(s.includes(','))s=s.replace(/\./g,'').replace(',','.'); else if(/^-?\d{1,3}(\.\d{3})+$/.test(s))s=s.replace(/\./g,''); const n=Number(s);return Number.isFinite(n)?n:0}; const out={name:found.name===undefined?'':String(found.name).trim()}; const set=(k,fn)=>{if(found[k]!==undefined)out[k]=fn(found[k])}; set('sku',v=>String(v).trim()); set('barcode',v=>String(v).trim()); set('category',v=>String(v).trim()||'Geral'); set('qty',v=>Math.max(0,Math.round(num(v)))); set('cost',v=>r2(Math.max(0,num(v)))); set('price',v=>r2(Math.max(0,num(v)))); set('min',v=>Math.max(0,Math.round(num(v)))); set('expiry',v=>validDate(v)); return out; } function parseCSVRows(text){ text=text.replace(/^\uFEFF/,''); const lines=text.split(/\r?\n/).filter(l=>l.trim()),first=lines.slice(0,10).join('\n'); const cnt=c=>(first.match(new RegExp(c==='\t'?'\\t':'\\'+c,'g'))||[]).length; const sep=[';',',','\t'].sort((a,b)=>cnt(b)-cnt(a))[0]; const rows=[];let row=[],f='',qt=false; for(let i=0;ix!==''))rows.push(row);row=[];f=''} else f+=c} row.push(f.trim());if(row.some(x=>x!==''))rows.push(row); return rows; } function rowsToObjects(rows){ let hi=-1; for(let i=0;itypeof c==='string'||typeof c==='number'?classifyHeader(c):'').filter(Boolean)); if(fs.has('name')&&fs.size>=2||fs.size>=3){hi=i;break} } if(hi<0)return[]; const used={},heads=rows[hi].map((h,i)=>{let n=String(h??'').trim()||'col'+i;if(used[n]){n+='__'+i}used[n]=1;return n}); return rows.slice(hi+1).filter(r=>r.some(x=>String(x??'').trim()!=='')).map(r=>Object.fromEntries(heads.map((h,i)=>[h,r[i]??'']))); } async function readProductFile(file){ const ext=file.name.toLowerCase().split('.').pop(); if(ext==='json'){const data=JSON.parse(await file.text());const arr=Array.isArray(data)?data:(data.products||data.produtos||data.items||[]);if(!Array.isArray(arr))throw Error('JSON não contém uma lista de produtos.');return arr.filter(x=>x&&typeof x==='object')} if(ext==='csv'||ext==='txt')return rowsToObjects(parseCSVRows(await file.text())); if(ext==='xlsx'||ext==='xls'){ if(!window.XLSX){await new Promise((res,rej)=>{const sc=document.createElement('script');sc.src='https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js';sc.onload=res;sc.onerror=()=>rej(Error('Não foi possível carregar o leitor Excel. Ligue-se à internet e tente novamente, ou guarde a folha como CSV.'));document.head.appendChild(sc)})} const wb=XLSX.read(await file.arrayBuffer(),{type:'array'});if(!wb.SheetNames.length)throw Error('O ficheiro Excel está vazio.'); for(const n of wb.SheetNames){const objs=rowsToObjects(XLSX.utils.sheet_to_json(wb.Sheets[n],{header:1,defval:'',raw:true}));if(objs.length)return objs} return[]; } throw Error('Formato não suportado. Use Excel, CSV ou JSON.'); } /* ---------- Leitura de códigos (câmera) ---------- */ let sfStream=null,sfScanTimer=null,sfScannerModal=null; function stopScanner(){if(sfScanTimer)clearTimeout(sfScanTimer);sfScanTimer=null;if(sfStream)sfStream.getTracks().forEach(t=>t.stop());sfStream=null;if(sfScannerModal)sfScannerModal.remove();sfScannerModal=null} function handleCode(code){ code=String(code).trim();if(!code)return; const found=db.products.find(p=>(p.sku&&p.sku.toLowerCase()===code.toLowerCase())||(p.barcode&&p.barcode.toLowerCase()===code.toLowerCase())); if(found){ if(can('edit_product')){if(confirm(`Código ${code} corresponde a “${found.name}”. Deseja editar este produto?`))productForm(found);else notify('Produto identificado: '+found.name)} else{selectProduct(found.id);notify('Produto identificado: '+found.name)} }else if(can('add_product')){productForm({sku:code});notify('Código lido. Complete os dados do novo produto.')} else notify('Nenhum produto com o código '+code+'.'); } function manualCode(reason){ modal('Introduzir código do produto',`${reason?`

${esc(reason)}

`:''}`,``); const go=()=>{const c=el('mcCode').value.trim();if(!c)return;closeModal();handleCode(c)}; el('mcOk').onclick=go;el('mcCode').addEventListener('keydown',e=>{if(e.key==='Enter')go()});setTimeout(()=>el('mcCode')?.focus(),50); } async function startProductScanner(onCode=handleCode,fallback=manualCode){ if(!navigator.mediaDevices?.getUserMedia)return fallback('A câmera exige HTTPS (ou abrir o ficheiro localmente). Hospede o sistema em HTTPS ou introduza o código manualmente.'); let detect=null,canvas=null; if('BarcodeDetector'in window){try{const want=['ean_13','ean_8','code_128','code_39','upc_a','upc_e','itf','qr_code'];let f=want;try{const sup=await BarcodeDetector.getSupportedFormats();f=want.filter(x=>sup.includes(x))}catch(e){}const d=f.length?new BarcodeDetector({formats:f}):new BarcodeDetector();detect=async v=>{const c=await d.detect(v);return c[0]?.rawValue||null}}catch(e){detect=null}} if(!detect&&window.ZXing){const Z=ZXing,h=new Map();h.set(Z.DecodeHintType.POSSIBLE_FORMATS,[Z.BarcodeFormat.EAN_13,Z.BarcodeFormat.EAN_8,Z.BarcodeFormat.UPC_A,Z.BarcodeFormat.UPC_E,Z.BarcodeFormat.CODE_128,Z.BarcodeFormat.CODE_39,Z.BarcodeFormat.ITF,Z.BarcodeFormat.QR_CODE]);h.set(Z.DecodeHintType.TRY_HARDER,true);const rd=new Z.MultiFormatReader();rd.setHints(h);canvas=document.createElement('canvas');detect=async v=>{const w=v.videoWidth,hh=v.videoHeight;if(!w)return null;canvas.width=w;canvas.height=hh;const cx=canvas.getContext('2d',{willReadFrequently:true});cx.drawImage(v,0,0,w,hh);try{return rd.decode(new Z.BinaryBitmap(new Z.HybridBinarizer(new Z.HTMLCanvasElementLuminanceSource(canvas)))).getText()}catch(e){return null}}} if(!detect)return fallback('Não foi possível carregar o leitor de códigos (verifique a internet). Introduza o código manualmente.'); try{ stopScanner(); sfScannerModal=document.createElement('div');sfScannerModal.className='sf-modal';sfScannerModal.style.display='flex'; sfScannerModal.innerHTML=`

Ler código do produto

Use a câmera traseira, boa iluminação e mantenha o código inteiro dentro da linha de leitura. Evite reflexos e mova lentamente até focar.

A procurar código…

`; document.body.appendChild(sfScannerModal); el('sfScanCancel').onclick=stopScanner;el('sfScanManual').onclick=()=>{stopScanner();fallback()}; let stream;try{stream=await navigator.mediaDevices.getUserMedia({video:{facingMode:{ideal:'environment'},width:{ideal:1920},height:{ideal:1080},focusMode:{ideal:'continuous'},advanced:[{focusMode:'continuous'}]},audio:false})}catch(e){stream=await navigator.mediaDevices.getUserMedia({video:true,audio:false})} if(!sfScannerModal){stream.getTracks().forEach(t=>t.stop());return} sfStream=stream;const video=el('sfCameraVideo');video.srcObject=stream;await video.play(); const scan=async()=>{ if(!sfScannerModal)return; try{if(video.readyState>=2){const code=await detect(video);if(code){safe(()=>navigator.vibrate&&navigator.vibrate(120));safe(()=>{const a=new (window.AudioContext||window.webkitAudioContext)(),o=a.createOscillator();o.frequency.value=1000;o.connect(a.destination);o.start();setTimeout(()=>{o.stop();a.close()},120)});stopScanner();onCode(code);return}}}catch(e){} sfScanTimer=setTimeout(scan,canvas?120:180); }; scan(); }catch(e){stopScanner();fallback('Não foi possível iniciar a câmera. Autorize o acesso nas permissões do navegador. '+(e.message||''))} } /* ---------- Ligações da interface ---------- */ const fsBtn=document.getElementById('sfFullscreenBtn'); if(fsBtn){const refreshFs=()=>{fsBtn.textContent=document.fullscreenElement?'⛶ Sair do ecrã inteiro':'⛶ Ecrã inteiro';};fsBtn.addEventListener('click',async()=>{try{if(!document.fullscreenElement){if(document.documentElement.requestFullscreen)await document.documentElement.requestFullscreen();else toast('O ecrã inteiro não é suportado neste navegador.');}else if(document.exitFullscreen)await document.exitFullscreen();}catch(e){toast('O navegador não permitiu o ecrã inteiro.');}});document.addEventListener('fullscreenchange',refreshFs);refreshFs();} document.querySelectorAll('[data-page]').forEach(b=>b.addEventListener('click',e=>{e.preventDefault();el('search').value='';go(b.dataset.page);el('side').classList.remove('open')})); document.querySelectorAll('.side-contacts a').forEach(a=>a.addEventListener('click',e=>{if(a.getAttribute('aria-disabled')==='true'){e.preventDefault();toast('Configure este contacto em Definições.')}})); document.querySelectorAll('[data-action]').forEach(b=>b.addEventListener('click',()=>{ el('search').value='';const a=b.dataset.action; if(a==='Nova Venda'){if(can('view_sales'))go('Vendas');movementForm('Venda')} else if(a==='Nova Compra'){if(can('view_purchases'))go('Compras');movementForm('Compra')} else if(a==='Adicionar Produto'){if(can('view_products'))go('Produtos');productForm()} else go(a); })); $('method').onclick=()=>go('Método de Valorização'); if($('openTerms'))$('openTerms').onclick=showTerms; $('all').onclick=()=>go('Movimentos'); $('menu').onclick=e=>{e.stopPropagation();$('side').classList.toggle('open')}; document.addEventListener('click',e=>{const s=$('side');if(s.classList.contains('open')&&!s.contains(e.target))s.classList.remove('open')}); $('logout').onclick=()=>{if(confirm('Terminar sessão?'))logout()}; $('search').addEventListener('input',()=>{if(!me())return;if(currentPage==='Painel Principal'&&$('search').value.trim()&&can('view_products')){currentPage='Produtos';setActive('Produtos');renderSection('Produtos')}else applyFilter()}); window.addEventListener('storage',e=>{ if(e.key!==STORE)return; db=load(); if(!db.users.length){logout('Os dados foram repostos noutro separador.');authShow('setup');return} if(activeId){if(!me()||me().disabled){logout('A sua conta foi removida, desactivada ou alterada noutro separador.');return}applyUserUI();const need=PAGE_PERM[currentPage];if((ADMIN_ONLY.includes(currentPage)&&!isAdmin())||(need&&!can(need))){go('Painel Principal');return}currentPage==='Painel Principal'?updateDash():renderSection(currentPage)} }); if(!safe(()=>{localStorage.setItem('__sf_t','1');localStorage.removeItem('__sf_t');return true},false))setTimeout(()=>alert('O armazenamento local deste navegador está bloqueado. Os dados NÃO serão guardados. Desactive o modo privado ou autorize o armazenamento.'),300); ['click','keydown','touchstart','mousemove'].forEach(ev=>document.addEventListener(ev,()=>{const n=Date.now();if(activeId&&n-lastAct>5000){lastAct=n;resetIdle()}},{passive:true})); /* ---------- Arranque ---------- */ tick();setInterval(tick,1000); function updateConnection(){const x=$('connectionStatus');if(x){x.textContent=navigator.onLine?'🟢 Online':'🔴 Offline';x.title=navigator.onLine?'Ligação de rede disponível':'Sem ligação de rede';}}window.addEventListener('online',updateConnection);window.addEventListener('offline',updateConnection);updateConnection(); applyTheme(db.settings.theme);applyBusiness(); (async()=>{ try{await migrate()}catch(e){console.error(e)} applyTheme(db.settings.theme);applyBusiness(); let accepted=false;try{accepted=localStorage.getItem('stockflow_terms_v4_1_accepted')==='yes'}catch(e){} if(!accepted){el('sfAuth').style.display='flex';showTerms(true);return;} continueStartup();cloudAutoResume(); })(); })(); /* Impressão de relatórios: activa a vista de relatório e abre a caixa de impressão. */ document.addEventListener('click',function(ev){ const b=ev.target && ev.target.closest ? ev.target.closest('#printReport') : null; if(!b)return; ev.preventDefault(); try { document.title='Relatório — StockFácil'; window.focus(); window.print(); } catch(err){ alert('Não foi possível iniciar a impressão. Utilize Ctrl+P no navegador.'); } },true);