⚡ VillanyStock 1.7.5

Biztonságos tulajdonosi belépés

`} function downloadReport(rid){ const r=(db.reports||[]).find(x=>x.id===rid);if(!r)return alert('Nem található a munkadokumentum.'); const W=1240,H=1754,margin=75,footerY=1670,pages=[],now=new Date();let canvas,ctx,y,pageNo=0; function fit(t,max){t=String(t??'');if(ctx.measureText(t).width<=max)return t;while(t.length&&ctx.measureText(t+'…').width>max)t=t.slice(0,-1);return t+'…';} function page(){canvas=document.createElement('canvas');canvas.width=W;canvas.height=H;ctx=canvas.getContext('2d');ctx.fillStyle='#fff';ctx.fillRect(0,0,W,H);pageNo++;y=margin; ctx.fillStyle='#173253';ctx.font='bold 28px "Times New Roman", Times, serif';ctx.fillText('VillanyStock – Lezárt munkalap',margin,y+29);y+=55; ctx.font='bold 21px "Times New Roman", Times, serif';ctx.fillText(fit(r.jobName,W-2*margin),margin,y+20);y+=36; ctx.font='22px "Times New Roman", Times, serif';ctx.fillStyle='#64748b';ctx.fillText('Lezárva: '+new Date(r.closedAt).toLocaleString('hu-HU')+' | Munkaazonosító: '+r.jobId,margin,y+15);y+=37; ctx.fillStyle='#e9f0fc';ctx.fillRect(margin,y,W-2*margin,52);ctx.fillStyle='#204b9e';ctx.font='bold 23px "Times New Roman", Times, serif'; ctx.fillText('Azonosító',margin+8,y+34);ctx.fillText('Felhasznált anyag',margin+185,y+34);ctx.textAlign='right';ctx.fillText('Mennyiség',W-margin-245,y+34);ctx.fillText('Érték (Ft)',W-margin-8,y+34);ctx.textAlign='left';y+=52;} function finish(){ctx.fillStyle='#dce4ed';ctx.fillRect(margin,footerY,W-2*margin,2);ctx.fillStyle='#64748b';ctx.font='19px "Times New Roman", Times, serif';ctx.fillText('Tájékoztató munkalap, nem számla.',margin,footerY+27);ctx.textAlign='right';ctx.fillText('Oldal '+pageNo,W-margin,footerY+27);ctx.textAlign='left';pages.push(canvas.toDataURL('image/jpeg',.87).split(',')[1]);} page();for(const item of r.items||[]){if(y+62>footerY){finish();page();} ctx.fillStyle='#22364f';ctx.font='23px "Times New Roman", Times, serif';ctx.fillText(fit(item.sku||'—',165),margin+8,y+33);ctx.fillText(fit(item.name,490),margin+185,y+33); ctx.textAlign='right';ctx.fillText(fmtQty(item.qty,item.unit)+' '+item.unit,W-margin-245,y+33);ctx.fillText(fmtFt(item.cost),W-margin-8,y+33);ctx.textAlign='left';ctx.fillStyle='#e6ecf4';ctx.fillRect(margin,y+59,W-2*margin,1);y+=60;} if(y+335>footerY){finish();page();} const material=Number(r.total||0),labor=Number(r.laborFee||0),travel=Number(r.travelCost||0); const sums=[['Anyagköltség',material],['Munkadíj',labor],['Utazási költség',travel],['Mindösszesen',material+labor+travel]]; y+=20;if(r.workDurationMs!=null){ctx.fillStyle='#173253';ctx.font='23px "Times New Roman", Times, serif';ctx.fillText('Tényleges munkaidő: '+(Number(r.workDurationMs)/3600000).toLocaleString('hu-HU',{minimumFractionDigits:2,maximumFractionDigits:2})+' óra',margin+15,y+22);y+=42;ctx.fillText('Óradíj: '+fmtFt(r.hourlyRate||0)+' Ft/óra',margin+15,y+22);y+=42;}for(let i=0;i{const b=typeof data==='string'?encoder.encode(data):data;parts.push(b);length+=b.length;}; add('%PDF-1.4\n');const count=2+pages.length*3;let obj=1;function object(id,content){offsets[id]=length;add(id+' 0 obj\n'+content+'\nendobj\n');} object(1,'<< /Type /Catalog /Pages 2 0 R >>');object(2,'<< /Type /Pages /Count '+pages.length+' /Kids ['+pages.map((_,i)=>(3+i*3)+' 0 R').join(' ')+'] >>'); pages.forEach((b64,i)=>{const pid=3+i*3,img=pid+1,content=pid+2;object(pid,'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /XObject << /Im'+i+' '+img+' 0 R >> >> /Contents '+content+' 0 R >>');const binary=atob(b64),bytes=new Uint8Array(binary.length);for(let k=0;k>\nstream\n');add(bytes);add('\nendstream\nendobj\n');const stream='q\n595 0 0 842 0 0 cm\n/Im'+i+' Do\nQ\n';object(content,'<< /Length '+encoder.encode(stream).length+' >>\nstream\n'+stream+'endstream');}); const xref=length;add('xref\n0 '+(count+1)+'\n0000000000 65535 f \n');for(let i=1;i<=count;i++)add(String(offsets[i]).padStart(10,'0')+' 00000 n \n');add('trailer\n<< /Size '+(count+1)+' /Root 1 0 R >>\nstartxref\n'+xref+'\n%%EOF'); const blob=new Blob(parts,{type:'application/pdf'}),url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download='VillanyStock-munka-'+r.closedAt.slice(0,10)+'-'+r.jobId.slice(0,8)+'.pdf';document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),30000); }function jobCard(j){const closed=!!j.closedAt,rows=closed?(db.reports||[]).find(r=>r.jobId===j.id)?.items||jobSummary(j.id):jobSummary(j.id),cost=rows.reduce((a,x)=>a+x.cost,0),lines=jobLines(j.id),r=(db.reports||[]).find(x=>x.jobId===j.id);return `
${esc(j.name)} · ${rows.length} anyagfajta · ${fmtFt(cost)} Ft ${closed?'· ✓ Lezárva':''}${xDeleteButton('job',j.id,'Munka')}
${closed?`

Lezárva: ${new Date(j.closedAt).toLocaleString('hu-HU',{dateStyle:'short',timeStyle:'medium'})}. Tényleges munkaidő: ${jobWorkText(j)}; óradíj: ${fmtFt(j.hourlyRate||0)} Ft/óra; munkadíj: ${fmtFt(j.laborFee||0)} Ft. Az összesítő a lezáráskor elmentésre került.

`:`

Munka anyagműveletek – varázsló

Korábbi kézi műveleti felület

QR / vonalkód – felhasználás, visszavétel, selejt

Felhasznált anyagok / elemek

Forrás munkadoboz: automatikusan felismerve.

`}

Összesített anyagfelhasználás

${rows.map(x=>``).join('')||''}
AnyagMennyiségÉrték
${esc(x.name)}${fmtQty(x.qty,x.unit)} ${esc(x.unit)}${fmtFt(x.cost)} Ft
Még nincs felhasználás.
${closed?'':`

Munka közben keletkezett selejt

${jobScrapLines(j.id).map(m=>{const p=db.products.find(x=>x.id===m.productId);return ``}).join('')||''}
AnyagMennyiségIndok
${esc(p?.name||'Törölt anyag')}${m.rawUnit==='g'?fmt(m.rawQuantity)+' g':fmtQty(-m.delta,p?.unit)+' '+esc(p?.unit||'')}${esc(m.note||'')}${xDeleteButton('movement',m.id,'Naplózott mozgás')}
Nincs selejt.
Tényleges munkaidő: ${jobWorkText(j)}${(j.workPaused||!j.workStartedAt)?' (szünetel)':' (folyamatban)'}
0 Ft
`}
`} let barcodeStream=null,barcodeTimer=null,barcodeBusy=false,barcodeDetector=null,barcodeSession=0,barcodeMode='product',barcodeJobId='',barcodeTorchOn=false; function barcodeStatus(message){const e=document.getElementById('barcode-status');if(e)e.textContent=message;} // VillanyStock 1.7.5 – QR-műveletek lépésenként, mentéskor naplózva. let vsWizard=null; function vsWizardStart(code='',context='warehouse',jid=''){ code=String(code||'').trim();if(!code)return alert('Először olvasd be a QR-kódot.'); if(vsBoxCodeInfo(code)){vsShowBoxLookup(code);return;} const product=activeProducts().find(p=>String(p.sku||'').trim().toLowerCase()===code.toLowerCase()); if(context==='job'||context==='box'){ const target=context==='box'?vsSelectedBox():null; if(!product){alert('Nem található termék ezzel a QR-azonosítóval a terméknyilvántartásban: '+code);return;} const productBox=target||vsJobProductBox(jid,product); if(!productBox||vsLocationQty(product,productBox)<=0){alert('A termék szerepel a nyilvántartásban, de nincs a '+(context==='box'?'kiválasztott munkadobozban':'munkához rendelt munkadobozban')+': '+product.name);return;} } vsWizard={code,context,jid,pid:product?.id||'',step:0,action:'',category:'materials',name:'',gpm:'',min:'',price:'',pos:['','',''],box:'',quantity:'',unit:'m',reason:'',source:'external',sourceBox:''}; vsWizardRender(); } function vsWizardChooseProduct(){ if(vsWizard){vsWizardExitPrompt();return;} const products=activeProducts().slice().sort((a,b)=>a.name.localeCompare(b.name,'hu')); const overlay=document.createElement('div');overlay.id='vs-wizard-picker';overlay.className='vs-wizard-overlay';overlay.setAttribute('role','dialog');overlay.setAttribute('aria-modal','true'); overlay.innerHTML='

Anyagmozgás · Termék kiválasztása

Keress név vagy QR-azonosító alapján. Egyszerre egy terméket válassz.

'; document.body.appendChild(overlay); const search=overlay.querySelector('#vs-picker-search'),results=overlay.querySelector('#vs-picker-results'); const render=()=>{const q=search.value.trim().toLocaleLowerCase('hu');const matches=products.filter(p=>(p.name+' '+p.sku).toLocaleLowerCase('hu').includes(q)).slice(0,60);results.innerHTML=matches.length?matches.map(p=>'').join(''):'

Nincs találat.

';}; const close=()=>overlay.remove(); overlay.querySelector('#vs-picker-close').onclick=close;overlay.querySelector('#vs-picker-cancel').onclick=close; search.oninput=render;results.onclick=e=>{const b=e.target.closest('[data-pid]');if(!b)return;const p=products.find(x=>x.id===b.dataset.pid);if(!p)return;close();vsWizardStart(p.sku);};render();search.focus(); } function vsNewProductWizard(){ if(vsWizard){vsWizardExitPrompt();return;} vsWizard={code:'',context:'newproduct',jid:'',pid:'',step:0,action:'',category:'materials',name:'',gpm:'',min:'',price:'',pos:['','',''],box:'',quantity:'',unit:'m',reason:'',source:'external',sourceBox:'',confirmed:true}; vsWizardRender(); } function vsNewProductScan(){vsWizardRead();barcodeMode=vsWizard?.context==='editproduct'?'editproduct':'newproduct';openBarcodeScanner();} function vsWizardBoxReturn(){const f=document.getElementById('vs-box-action');const p=db.products.find(x=>x.id===f?.elements?.product?.value&&!x.deletedAt);if(!p)return alert('Válassz terméket a munkadobozból.');vsWizardStart(p.sku,'box');if(vsWizard){vsWizard.action='box_return';vsWizard.sourceBox=vsSelectedBox();vsWizard.step=0;vsWizardRender();}} function vsWizardClose(force=false){ if(!vsWizard)return; if(!force){vsWizardRead();vsWizardExitPrompt();return;} vsWizard=null;document.getElementById('vs-wizard-overlay')?.remove();document.removeEventListener('keydown',vsWizardKeyGuard,true); } function vsWizardExitPrompt(){ const root=document.getElementById('vs-wizard-overlay');if(!root||root.querySelector('.vs-wizard-exit'))return; const dialog=document.createElement('div');dialog.className='vs-wizard-exit';dialog.setAttribute('role','alertdialog');dialog.setAttribute('aria-modal','true');dialog.innerHTML='

Megszakítod a műveletet?

Biztos vagy benne, hogy nem folytatod? A még nem mentett adatok elvesznek.

'; root.appendChild(dialog);dialog.querySelector('button').focus(); } function vsWizardExitNo(){document.querySelector('.vs-wizard-exit')?.remove();document.querySelector('.vs-wizard-card button')?.focus()} function vsWizardKeyGuard(e){if(!vsWizard)return;if(e.key==='Escape'){e.preventDefault();e.stopPropagation();vsWizardExitPrompt();return;}if(e.key!=='Tab')return;const target=document.querySelector('.vs-wizard-exit')||document.querySelector('.vs-wizard-card');if(!target)return;const els=[...target.querySelectorAll('button,input,select,textarea,[tabindex]')].filter(x=>!x.disabled&&x.getClientRects().length);if(!els.length)return;const first=els[0],last=els[els.length-1];if(e.shiftKey&&document.activeElement===first){e.preventDefault();last.focus()}else if(!e.shiftKey&&document.activeElement===last){e.preventDefault();first.focus()}else if(!target.contains(document.activeElement)){e.preventDefault();first.focus()}} function vsWizardSet(k,v){if(vsWizard)vsWizard[k]=v} function vsWizardRead(){const w=vsWizard,root=document.getElementById('vs-wizard-overlay');if(!w||!root)return; for(const el of root.querySelectorAll('[data-wizard]')){if(el.type==='radio'){if(el.checked)w[el.dataset.wizard]=el.value}else if(el.type==='checkbox'){if(el.checked)w[el.dataset.wizard]=el.value}else w[el.dataset.wizard]=el.value} for(let i=0;i<3;i++){const el=root.querySelector('[data-pos="'+i+'"]');if(el)w.pos[i]=el.value.toUpperCase().replace(/[^A-Z0-9]/g,'')} } function vsWizardSteps(w,p){ if(!p||w.context==='editproduct')return (w.context==='newproduct'||w.context==='editproduct'?['code','name','category','gpm','min','price','position','review']:['new','confirm','name','category','gpm','min','price','position','review']).filter(x=>x!=='gpm'||w.category==='wires'); if(w.action==='box_return')return ['sourceBox','unit','quantity','review'].filter(x=>x!=='unit'||p.unit==='m'); if(!w.action)return ['action']; const steps=['action']; /* A bevételezés mindig külső forrásból történik. */ if(w.action==='transfer')steps.push('box'); if(p.unit==='m')steps.push('unit'); steps.push('quantity'); if(['scrap','job_scrap'].includes(w.action))steps.push('reason'); steps.push('review');return steps; } function vsWizardCurrent(w,p){const steps=vsWizardSteps(w,p);return steps[Math.min(w.step,steps.length-1)];} function vsWizardBack(){vsWizardRead();const w=vsWizard;if(!w)return;/* A termékadatlapról közvetlenül indított műveletnél az 'action' lépés nem volt látható. */if(w.returnOverviewPid&&w.step<=1){const pid=w.returnOverviewPid;vsWizardClose(true);vsProductOverviewOpen(pid);return;}if(w.step>0){w.step--;vsWizardRender();return;}vsWizardClose()} function vsWizardNext(){vsWizardRead();const w=vsWizard;if(!w)return;const p=db.products.find(x=>x.id===w.pid&&!x.deletedAt),step=vsWizardCurrent(w,p); if(!p||w.context==='editproduct'){ if(step==='code'){if(!w.code.trim())return alert('Írd be vagy olvasd be a termék QR-azonosítóját.');if(activeProducts().some(x=>x.id!==w.pid&&String(x.sku||'').trim().toLowerCase()===w.code.trim().toLowerCase())||vs161CodeAssignedBox(w.code))return alert('Ez a QR-azonosító már létezik.');} if(step==='confirm'&&!w.confirmed)return alert('Olvasd be újra a QR-kódot.'); if(step==='name'&&(!w.name.trim()||vsExistingProductName(w.name,w.pid)))return alert('Adj meg egyedi terméknevet.'); if(step==='gpm'&&!(Number(w.gpm)>0))return alert('Pozitív g/m érték szükséges.'); if(step==='min'&&(!Number.isFinite(Number(w.min))||Number(w.min)<0||(w.category!=='wires'&&!Number.isInteger(Number(w.min)))))return alert('Hibás minimumkészlet.'); if(step==='price'&&(!Number.isFinite(Number(w.price))||Number(w.price)<0))return alert('Hibás egységár.'); if(step==='position'){try{normalizeStockPosition(w.pos[0]+'/'+w.pos[1]+'-'+w.pos[2]);}catch(e){return alert(e.message)} /* A foglalt pozíciót csak a végső mentéskor erősítjük meg. */} }else{ if(step==='action'&&!w.action)return alert('Válassz műveletet.'); if(step==='sourceBox'&&(!vsBoxes().includes(w.sourceBox)||vsLocationQty(p,w.sourceBox)<=0))return alert('Válassz készlettel rendelkező munkadobozt.'); if(step==='box'&&!vsBoxes().includes(w.box))return alert('Válassz munkadobozt.'); if(step==='unit'&&p.unit==='m'&&w.unit==='g'&&!(p.gpm>0))return alert('Hiányzik a termék g/m értéke.'); if(step==='quantity'){const raw=Number(w.quantity),q=p.unit==='m'&&w.unit==='g'?raw/Number(p.gpm):raw; if(!Number.isFinite(q)||q<=0||(p.unit==='db'&&!Number.isInteger(q)))return alert('Adj meg érvényes pozitív mennyiséget.'); const box=w.context==='job'?vsJobProductBox(w.jid,p):'',loc=w.context==='job'?box:VS_WAREHOUSE; const max=w.action==='in'?Infinity:w.action==='job_return'?vsJobReturnBalance(w.jid,p.id,box):w.action==='box_return'?vsLocationQty(p,w.sourceBox):vsLocationQty(p,loc); if(q>max+1e-8)return alert('A megadott mennyiség meghaladja a rendelkezésre álló készletet.'); } if(step==='reason'&&!w.reason.trim())return alert('A selejtezés indoklása kötelező.'); } if(step==='review')return vsWizardSave(); w.step=Math.min(w.step+1,vsWizardSteps(w,p).length-1);vsWizardRender(); } function vsWizardRender(){const w=vsWizard;if(!w)return;const p=db.products.find(x=>x.id===w.pid&&!x.deletedAt),isNew=!p||w.context==='editproduct',job=w.context==='job',steps=vsWizardSteps(w,p),step=vsWizardCurrent(w,p); const titles={in:'Bevételezés',box_return:'Visszaadás a raktárba',transfer:'Anyagmozgatás munkadobozba',scrap:'Selejtezés',job_use:'Felhasználás',job_return:'Visszavétel a munkadobozba',job_scrap:'Selejtezés'}; const choices=(key,items)=>'
'+items.map(([v,label])=>'').join('')+'
'; let body='',heading=w.context==='editproduct'?'Termékadatok módosítása':isNew?'Új termék felvétele':esc(p.name); if(isNew){ if(step==='code')body='

'+(w.context==='editproduct'?'Olvasd be azt a QR-kódot, amelyhez a terméket hozzá szeretnéd rendelni!':'Olvasd be a termék QR-kódját!')+'

A kamera alatt a Manuális bevitel gombbal válthatsz gépelésre.

'; if(step==='new')body='

Ismeretlen QR-azonosító: '+esc(w.code)+'

Új termék felvétele következik.

'; if(step==='confirm')body='

QR-azonosító: '+esc(w.code)+'

'+(w.confirmed?'✓ Megerősítve':'Még nincs megerősítve')+'

'; if(step==='name')body=''; if(step==='category')body='

Anyagkategória

'+choices('category',['wires','materials','cabinets','distribution','automation','lighting'].map(x=>[x,categoryName(x)])); if(step==='gpm')body=''; if(step==='min')body=''; if(step==='price')body=''; if(step==='position')body='
'+w.pos.map((v,i)=>''+(i<2?''+(i===0?'/':'-')+'':'')).join('')+'
'; if(step==='review')body='

QR: '+esc(w.code)+'

Név: '+esc(w.name)+'

Kategória: '+esc(categoryName(w.category))+'

'+(w.category==='wires'?'

g/m: '+esc(w.gpm)+'

':'')+'

Minimum: '+esc(w.min)+'

Egységár: '+esc(w.price)+' Ft

Pozíció: '+esc(w.pos.join('/').replace(/\/([^/]*)$/,'-$1'))+'

'; }else{ const box=job?vsJobProductBox(w.jid,p):'',loc=job?box:VS_WAREHOUSE,available=loc?vsLocationQty(p,loc):0; if(step==='action'){body='

'+esc(p.sku)+' · Raktár: '+fmtQty(warehouseQty(p),p.unit)+' '+esc(p.unit)+'

Válassz egy műveletet:

';const actions=job?[['job_use','Felhasználás'],['job_return','Visszavétel'],['box_return','Visszaadás a raktárba'],['job_scrap','Selejtezés']]:[['in','Bevételezés'],['transfer','Anyagmozgatás munkadobozba'],['scrap','Selejtezés']];body+=choices('action',actions);} if(step==='sourceBox')body='

Válaszd ki a forrás munkadobozt:

'+choices('sourceBox',vsBoxes().filter(b=>vsLocationQty(p,b)>0).map(b=>[b,b+' · '+fmtQty(vsLocationQty(p,b),p.unit)+' '+p.unit])); if(step==='box')body='

Válaszd ki a cél munkadobozt:

'+choices('box',vsBoxes().map(b=>[b,b])); if(step==='unit')body='

Milyen mértékegységben adod meg a mennyiséget?

'+choices('unit',[['m','Méter (m)'],['g','Gramm (g)']]); if(step==='quantity'){const max=w.action==='job_return'?vsJobReturnBalance(w.jid,p.id,box):w.action==='box_return'?vsLocationQty(p,w.sourceBox):available;body='

'+esc(titles[w.action])+(w.action==='in'&&w.source==='external'?' · Külső beszerzés':' · Elérhető: '+(p.unit==='m'&&w.unit==='g'?fmt(Math.round(max*Number(p.gpm||0)))+' g':fmtQty(max,p.unit)+' '+esc(p.unit)))+'

';} if(step==='reason')body=''; if(step==='review'){const raw=Number(w.quantity),q=p.unit==='m'&&w.unit==='g'?raw/Number(p.gpm):raw;body='

Termék: '+esc(p.name)+'

Művelet: '+esc(titles[w.action])+'

'+(w.action==='box_return'?'

Forrás: '+esc(w.sourceBox)+'

':'')+(w.action==='transfer'?'

Cél: '+esc(w.box)+'

':'')+(job?'

Munka: '+esc(db.jobs.find(j=>j.id===w.jid)?.name||'')+'

':'')+'

Mennyiség: '+esc(w.quantity)+' '+esc(p.unit==='m'?w.unit:p.unit)+'

'+(p.unit==='m'&&w.unit==='g'?'

Átváltva: '+fmtQty(q,p.unit)+' m

':'')+(w.reason?'

Indok: '+esc(w.reason)+'

':'')+'

A készlet csak a Mentés megnyomásakor változik.

';} } const last=step==='review',overlay=document.getElementById('vs-wizard-overlay')||document.createElement('div');overlay.id='vs-wizard-overlay';overlay.className='vs-wizard-overlay';overlay.setAttribute('role','dialog');overlay.setAttribute('aria-modal','true');overlay.innerHTML='

'+heading+'

'+(last?'Áttekintés · ': 'Lépés ')+(w.step+1)+' / '+steps.length+'

'+body+'
';if(!overlay.isConnected){document.body.appendChild(overlay);document.addEventListener('keydown',vsWizardKeyGuard,true);}overlay.querySelector('.vs-wizard-card button')?.focus();if(step==='code')vs164CameraFirst(w,w.step,w.context==='editproduct'?'editproduct':'newproduct'); } function vsWizardRescan(){vsWizardRead();barcodeMode='wizard';openBarcodeScanner()} function vsWizardRescanned(code){if(!vsWizard)return;if(vsWizard.context==='editproduct'){vsWizard.code=code;vsWizardRender();return;}if(code!==vsWizard.code)return alert('Nem ugyanazt a QR-kódot olvastad be.');vsWizard.confirmed=true;const status=document.getElementById('vs-wizard-rescan-status');if(status)status.textContent='✓ QR-kód megerősítve';} /* 1.1: közös készletmozgás-ellenőrzés a QR-varázsló és a munkadoboz-varázsló számára. */ function vsStockValidateMovement(m){ const p=db.products.find(x=>x.id===m?.productId&&!x.deletedAt); if(!p)return 'A termék nem található az aktív készletben.'; if(!['in','box_transfer','scrap','job_use','job_return'].includes(m.type))return 'Ismeretlen készletművelet.'; const transfer=m.type==='box_transfer'; const q=transfer?Number(m.quantity):Math.abs(Number(m.delta)); if(!Number.isFinite(q)||q<=0)return 'A mennyiségnek pozitív számnak kell lennie.'; if(p.unit==='db'&&!Number.isInteger(q))return 'Darabos terméknél egész mennyiség szükséges.'; if(db.movements.some(x=>x.id===m.id))return 'Ez a készletmozgás már szerepel a naplóban.'; if(transfer){ if(!m.fromLocation||!m.toLocation||m.fromLocation===m.toLocation)return 'A forrás és cél munkaterület nem megfelelő.'; if(m.fromLocation!==VS_WAREHOUSE&&!vsBoxes().includes(m.fromLocation))return 'A forrás munkadoboz nem létezik.'; if(m.toLocation!==VS_WAREHOUSE&&!vsBoxes().includes(m.toLocation))return 'A cél munkadoboz nem létezik.'; if(q>vsLocationQty(p,m.fromLocation)+1e-8)return 'Nincs elegendő készlet a forrás helyen.'; const conflict=vsUniqueBoxTransferError(p,m.fromLocation,m.toLocation,q); if(conflict)return conflict; }else if(m.type==='in'){ if(!(Number(m.delta)>0))return 'A bevételezés mennyisége hibás.'; }else if(m.type==='job_return'){ if(!(Number(m.delta)>0)||!m.jobId||!db.jobs.some(j=>j.id===m.jobId&&!j.closedAt))return 'A munkához tartozó visszavétel nem érvényes.'; if(q>vsJobReturnBalance(m.jobId,p.id,m.location)+1e-8)return 'A visszavétel meghaladja a kiadott mennyiséget.'; }else{ if(!(Number(m.delta)<0))return 'A készletcsökkentés előjele hibás.'; if(q>vsLocationQty(p,m.location||VS_WAREHOUSE)+1e-8)return 'Nincs elegendő készlet.'; if(m.type==='job_use'&&(!m.jobId||!db.jobs.some(j=>j.id===m.jobId&&!j.closedAt)))return 'A munka már nem aktív.'; if(m.type==='scrap'&&!String(m.note||'').trim())return 'A selejtezés indoklása kötelező.'; } return ''; } function vsStockCommit(m){ const error=vsStockValidateMovement(m); if(error){alert(error);return false;} db.movements.push(m);return true; } /* 2.0: all-or-nothing, prevalidated batch ledger; no partial stock mutation. */ function vsStockBatchCommit(entries){ if(!Array.isArray(entries)||!entries.length)return {ok:false,error:'Nincs rögzíthető tétel.'}; const before=db.movements.slice(); try{ const seen=new Set(); for(const m of entries){ if(!m.id||seen.has(m.id))return {ok:false,error:'Duplikált műveletazonosító.'}; seen.add(m.id); const error=vsStockValidateMovement(m); if(error)return {ok:false,error}; db.movements.push(m); } return {ok:true,count:entries.length}; }finally{ // Only a fully valid batch may survive; any invalid batch rolls back. if(db.movements.length!==before.length+entries.length)db.movements=before; } } async function vsWizardSave(){vsWizardRead();const w=vsWizard;if(!w)return;let p=db.products.find(x=>x.id===w.pid&&!x.deletedAt); if(w.context==='editproduct'){ if(!p)return alert('A termék már nem található.'); const sku=String(w.code||'').trim(),name=String(w.name||'').trim(),category=w.category,unit=category==='wires'?'m':'db',gpm=Number(w.gpm),min=Number(w.min),price=Number(w.price); if(!sku||!name)return alert('Az azonosító és a név kötelező.'); if(activeProducts().some(x=>x.id!==p.id&&String(x.sku||'').trim().toLowerCase()===sku.toLowerCase())||vs161CodeAssignedBox(sku))return alert('Ez a QR-azonosító már létezik.'); if(vsRejectDuplicateProductName(name,p.id))return; if(!Number.isFinite(min)||min<0||!Number.isFinite(price)||price<0||(unit==='db'&&!Number.isInteger(min)))return alert('Hibás minimum vagy egységár.'); if(category==='wires'&&(!Number.isFinite(gpm)||gpm<=0))return alert('Pozitív g/m szükséges.'); if(category!==categoryOf(p)&&qty(p)!==0)return alert('Kategóriát csak nulla készletnél módosíthatsz.'); let position;try{position=normalizeStockPosition(w.pos[0]+'/'+w.pos[1]+'-'+w.pos[2]);}catch(e){return alert(e.message)} if(!vsCheckUniquePosition(position,p.id))return; const before={sku:p.sku,name:p.name,category:categoryOf(p),unit:p.unit,gpm:p.gpm,min:p.min,price:p.price,position:p.position||''}; Object.assign(p,{sku,name,category,unit,gpm:category==='wires'?gpm:0,min,price,position}); recordAudit('product_edited',p,'Termékadatok módosítása',{before,after:{sku,name,category,unit,gpm:p.gpm,min,price,position}}); save();vsWizardClose(true);if(!vs162FinishGlobal())view('stock');return; } if(!p){if(!w.confirmed)return alert('Az új termék QR-kódját olvasd be újra.');if(!w.code.trim())return alert('Hiányzó termékazonosító.');let position;try{position=normalizeStockPosition(w.pos[0]+'/'+w.pos[1]+'-'+w.pos[2])}catch(e){return alert(e.message)}if(vsExistingProductName(w.name))return alert('A terméknév már létezik.');if(activeProducts().some(x=>x.sku.toLowerCase()===w.code.toLowerCase())||vs161CodeAssignedBox(w.code))return alert('A QR-kód már létezik.');if(!vsCheckUniquePosition(position))return;const category=w.category,unit=category==='wires'?'m':'db';p={id:id(),sku:w.code,name:w.name.trim(),position,category,unit,gpm:category==='wires'?Number(w.gpm):0,min:Number(w.min),price:Number(w.price)};db.products.push(p);save();vsWizardClose(true);if(!vs162FinishGlobal())view('stock');return;} if(['in','box_return'].includes(w.action)&&vsWizardCurrent(w,p)!=='review')return alert('Hiányzó lépés.');const raw=Number(w.quantity),q=p.unit==='m'&&w.unit==='g'?raw/Number(p.gpm):raw; if(!Number.isFinite(q)||q<=0||(p.unit==='db'&&!Number.isInteger(q)))return alert('Hibás mennyiség.');if(p.unit==='m'&&w.unit==='g'&&!(p.gpm>0))return alert('Hiányzik a g/m érték.'); const job=w.context==='job',box=job?vsJobProductBox(w.jid,p):'',loc=job?box:VS_WAREHOUSE; if(job&&w.action!=='box_return'&&(!box||!vsSelectedBoxes().includes(box)))return alert('A termék nem található kijelölt munkadobozban.'); if(job&&w.action!=='box_return'&&!db.jobs.some(j=>j.id===w.jid&&!j.closedAt))return alert('A munka már nem aktív.'); if(w.action==='transfer'){if(!vsBoxes().includes(w.box))return alert('Válassz munkadobozt.');if(q>warehouseQty(p)+1e-8)return alert('Nincs elég raktárkészlet.');const conflict=vsUniqueBoxTransferError(p,VS_WAREHOUSE,w.box,q);if(conflict)return alert(conflict);if(!vsStockCommit({id:id(),at:stamp(),productId:p.id,type:'box_transfer',delta:0,quantity:q,fromLocation:VS_WAREHOUSE,toLocation:w.box,note:'Átcsoportosítás raktár → '+w.box}))return;} else if(w.action==='in'||w.action==='box_return'){const from=w.action==='box_return'?w.sourceBox:null;if(from){if(!vsBoxes().includes(from)||q>vsLocationQty(p,from)+1e-8)return alert('Nincs elegendő készlet a forrás munkadobozban.');if(!vsStockCommit({id:id(),at:stamp(),productId:p.id,type:'box_transfer',delta:0,quantity:q,fromLocation:from,toLocation:VS_WAREHOUSE,note:'QR visszaadás '+from+' → raktár'}))return;}else{if(!vsStockCommit({id:id(),at:stamp(),productId:p.id,type:'in',delta:q,location:VS_WAREHOUSE,note:'QR-bevételezés – új raktári készlet',rawQuantity:raw,rawUnit:w.unit}))return;}} else if(w.action==='job_return'){if(q>vsJobReturnBalance(w.jid,p.id,box)+1e-8)return alert('A visszavétel meghaladja a munkához kiadott mennyiséget.');if(!vsStockCommit({id:id(),at:stamp(),productId:p.id,type:'job_return',delta:q,jobId:w.jid,location:box,note:'Munkából visszavétel → '+box,rawQuantity:raw,rawUnit:w.unit}))return;recordAudit('job_material_returned',p,'QR visszavétel',{jobId:w.jid,quantity:q,location:box});} else if(['scrap','job_scrap','job_use'].includes(w.action)){if(q>vsLocationQty(p,loc)+1e-8)return alert('Nincs elegendő készlet.');if(w.action!=='job_use'&&!w.reason.trim())return alert('A selejtezés oka kötelező.');const scrap=w.action!=='job_use';if(!vsStockCommit({id:id(),at:stamp(),productId:p.id,type:scrap?'scrap':'job_use',delta:-q,location:loc,jobId:job?w.jid:'',note:scrap?w.reason:'QR munkafelhasználás',rawQuantity:raw,rawUnit:w.unit,gpmAtUse:p.gpm||0,priceAtUse:p.price||0}))return;recordAudit(scrap?'stock_scrapped':'job_material_used',p,scrap?w.reason:'QR munkafelhasználás',{jobId:w.jid||'',delta:-q,location:loc});} else return alert('Ismeretlen művelet.'); save();const jid=w.jid;vsWizardClose(true);if(vs162FinishGlobal())return;view(job?'jobs':'stock');if(job){const detail=document.getElementById('job-'+jid);if(detail)detail.open=true;} } function barcodeTarget(){if(barcodeMode==='vs1716add'||barcodeMode==='vs1732target')return document.getElementById('vs1716-box-overlay');if(barcodeMode==='vs2inventory'||barcodeMode==='globallookup')return document.getElementById('vs2-tools-overlay');if(barcodeMode==='wizard'||barcodeMode==='newproduct'||barcodeMode==='editproduct')return document.getElementById('vs-wizard-overlay');if(barcodeMode==='jobusagebox')return document.getElementById('vs-job-usage-overlay');if(barcodeMode==='jobboxes52')return document.getElementById('vs-j52-overlay')||document.getElementById('vs-jw-overlay');if(barcodeMode==='boxmanager')return document.getElementById('vs-box-manager-overlay');if(barcodeMode==='newboxqr')return document.getElementById('vs-newbox-qr');if(barcodeMode==='bwboxes'||barcodeMode==='bwaddproduct')return document.getElementById('vs-bw-overlay');if(barcodeMode==='jobselect')return document.getElementById('vs-j52-overlay')||document.getElementById('vs-jw-overlay');if(barcodeMode==='workbox')return document.getElementById('vs-box-code');if(barcodeMode==='transferproduct')return document.getElementById('vs-transfer-code');if(barcodeMode==='boxproduct')return document.getElementById('vs-box-product-code');return barcodeMode==='job'?document.getElementById('job-qr-code-'+barcodeJobId):barcodeMode==='lookup'?document.getElementById('dashboard-qr-code'):barcodeMode==='quickjob'?document.getElementById('quick-job-code'):document.getElementById('new-product-sku');} function barcodeTorchUI(available){const button=document.getElementById('barcode-torch');if(!button)return;button.hidden=!available;button.setAttribute('aria-pressed',String(barcodeTorchOn));const label=document.getElementById('barcode-torch-label');if(label)label.textContent=barcodeTorchOn?'Zseblámpa kikapcsolása':'Zseblámpa bekapcsolása';} async function toggleBarcodeTorch(){const track=barcodeStream?.getVideoTracks()[0];if(!track||typeof track.applyConstraints!=='function')return;const session=barcodeSession;const next=!barcodeTorchOn;const button=document.getElementById('barcode-torch');if(button)button.disabled=true;try{await track.applyConstraints({advanced:[{torch:next}]});if(session!==barcodeSession)return;barcodeTorchOn=next;barcodeTorchUI(true);}catch(err){if(session===barcodeSession){barcodeStatus('A zseblámpa nem kapcsolható ezen a készüléken: '+err.message);barcodeTorchUI(false);}}finally{if(button)button.disabled=false;}} function closeBarcodeScanner(){barcodeSession++;barcodeTorchOn=false;barcodeTorchUI(false);if(barcodeTimer){clearTimeout(barcodeTimer);barcodeTimer=null;}if(barcodeStream){barcodeStream.getTracks().forEach(t=>t.stop());barcodeStream=null;}const video=document.getElementById('barcode-video');if(video){video.pause();video.srcObject=null;}const modal=document.getElementById('barcode-scanner');if(modal)modal.hidden=true;barcodeBusy=false;} // 1.6.4: ugyanazt a kameraolvasót használjuk minden QR-beviteli helyen. // A munkamenet-azonosító megakadályozza, hogy egy varázsló újrarajzolása // vagy a kézi mód kiválasztása ismét megnyissa a kamerát. const vs164Sessions=new WeakMap(); function vs164CameraFirst(owner,step,mode){ if(!owner)return; const key=String(step); if(vs164Sessions.get(owner)===key)return; vs164Sessions.set(owner,key); setTimeout(()=>{ if((owner===vsWizard||owner===vsBW||owner===vsJW||owner===vsJ52)&& (owner.step===step||typeof step==='number'&&owner.step===step)&& document.getElementById('barcode-scanner')?.hidden!==false){ barcodeMode=mode;openBarcodeScanner(); } },80); } function vs164ScannerInstruction(){ const e=document.getElementById('vs164-scan-instruction');if(!e)return; const editing=barcodeMode==='editproduct'&&vsWizard?.context==='editproduct'; const jobBox=barcodeMode==='jobboxes52'&&vsJ52&&['newBoxes','activeBoxes'].includes(vsJ52.step); e.hidden=!(editing||jobBox); e.textContent=jobBox?'Olvasd be a munkadoboz QR-kódját, vagy írd be kézzel.':editing?'Olvasd be azt a QR-kódot, amelyhez a terméket hozzá szeretnéd rendelni!':''; } function vs163ManualEntry(){ const mode=barcodeMode; closeBarcodeScanner(); if(mode==='globallookup'){document.getElementById('vs2-global-input')?.focus();return;} if(mode==='jobusagebox'){document.getElementById('vs-job-usage-manual')?.focus();return;}if(mode==='boxmanager'){vsBoxManagerManual();return;}if(mode==='newboxqr'){vsNewBoxManual();return;} const ids={editproduct:'',newproduct:'',wizard:'',bwboxes:'',jobboxes52:'',bwaddproduct:'vs-bw-search',jobselect:vsJ52?.step==='search'?'vs-j52-search':'vs-jw-input',workbox:'vs-box-code',transferproduct:'vs-transfer-code',boxproduct:'vs-box-product-code',quickjob:'quick-job-code',newproduct:'new-product-sku',product:'new-product-sku'}; const field=document.getElementById(ids[mode]||''); if(field){field.focus();return;} // Some wizards render their input only after a step change; leave the current step intact. const active=document.querySelector('#vs-wizard-overlay input:not([type=hidden]), #vs-bw-overlay input:not([type=hidden]), #vs-j52-overlay input:not([type=hidden])'); active?.focus(); } function vs163OpenCamera(mode){ if(document.getElementById('barcode-scanner')?.hidden===false)return; barcodeMode=mode;openBarcodeScanner(); } function barcodeDetected(value){const code=String(value||'').trim();if(!code)return;const mode=barcodeMode;closeBarcodeScanner();if(mode==='jobusagebox'){vsJobUsageSelectBox(code);return;}if(mode==='vs1716add'){vs1716AddScan(code);return;}if(mode==='vs1732target'){vs1732TargetScan(code);return;}if(mode==='boxmanager'){vsBoxManagerScanned(code);return;}if(mode==='newboxqr'){vsSaveNewBox(code);return;}if(mode==='globallookup'){vs161Resolve(code);return;}if(mode==='bwaddproduct'){vs161BoxProductScanned(code);return;}if(mode==='vs2inventory'){vs2InventoryScanned(code);return;}if(mode==='bwboxes'){if(vsBW){const b=code.toUpperCase().replace(/^VSBOX:/,'');if(vsBoxes().includes(b)){if(!vsBW.boxes.includes(b))vsBW.boxes.push(b);vsBWRender()}else alert('Ismeretlen munkadoboz: '+code)}return;}if(mode==='jobselect'){if(vsJ52&&vsJ52.step==='search'){vsJ52ResolveScan(code)}else if(vsJW){vsJW.search=code;vsJWRender()}return;}if(mode==='jobboxes52'){if(vsJ52){vsJ52AddScannedBox(code)}return;}if(mode==='workbox'){vsLookupBox(code);return;}if(mode==='transferproduct'){vsTransferProductLookup(code);return;}if(mode==='boxproduct'){vsBoxProductLookup(code);return;}if(mode==='editproduct'){if(vsWizard&&vsWizard.context==='editproduct'){vsWizard.code=code;vsWizardRender();}return;}if(mode==='newproduct'){if(vsWizard&&vsWizard.context==='newproduct'){vsWizard.code=code;vsWizardRender();}return;}if(mode==='wizard'){vsWizardRescanned(code);return;}if(mode==='lookup'){if(!vsShowBoxLookup(code))vsWizardStart(code);return;}if(mode==='job'){vsWizardStart(code,'job',barcodeJobId);return;}if(mode==='quickjob'){const field=document.getElementById('quick-job-code');if(field)field.value=code;quickJobLookup();return;}const target=document.getElementById('new-product-sku');if(!target)return;target.value=code;target.dispatchEvent(new Event('input',{bubbles:true}));target.focus();} function lowStockProducts(){return activeProducts().filter(p=>warehouseQty(p)<=0||warehouseQty(p)a.name.localeCompare(b.name,'hu')); if(!products.length)return alert('Nincs készlethiányos termék.'); const W=1240,H=1754,margin=82,footerY=1684,rows=[]; const now=new Date(); for(const p of products){const q=warehouseQty(p),minimum=Number(p.min||0),shortage=Math.max(0,minimum-q),unit=p.unit||'db';rows.push({name:p.name,sku:p.sku||'',position:p.position||'Nincs megadva',quantity:fmtQty(q,unit)+' '+unit,minimum:fmtQty(minimum,unit)+' '+unit,shortage:fmtQty(shortage,unit)+' '+unit});} const pages=[];let canvas,ctx,y,pageNo=0,lastCategory=null; const ROW_HEIGHT=93; function page(){canvas=document.createElement('canvas');canvas.width=W;canvas.height=H;ctx=canvas.getContext('2d');ctx.fillStyle='#fff';ctx.fillRect(0,0,W,H);y=margin;pageNo++;lastCategory=null; ctx.fillStyle='#172d4b';ctx.font='bold 25px "Times New Roman", Times, serif';ctx.fillText('VillanyStock – Készlethiány',margin,y+28);y+=46; ctx.font='19px "Times New Roman", Times, serif';ctx.fillStyle='#64748b';ctx.fillText('Készült: '+now.toLocaleString('hu-HU')+' | Hiányos termékek: '+products.length,margin,y+13);y+=32; ctx.fillStyle='#e5eaf2';ctx.fillRect(margin,y,W-2*margin,2);y+=19;} function finish(){ctx.fillStyle='#e5eaf2';ctx.fillRect(margin,footerY,W-2*margin,2);ctx.fillStyle='#64748b';ctx.font='19px "Times New Roman", Times, serif';ctx.fillText('VillanyStock • Csak raktári készlet, munkadobozok nélkül',margin,footerY+30);ctx.textAlign='right';ctx.fillText(String(pageNo)+'. oldal',W-margin,footerY+30);ctx.textAlign='left';pages.push(canvas.toDataURL('image/jpeg',0.92).split(',')[1]);} function fitText(t,maxWidth,font){ctx.font=font;let v=String(t);if(ctx.measureText(v).width<=maxWidth)return v;while(v.length>1&&ctx.measureText(v+'…').width>maxWidth)v=v.slice(0,-1);return v+'…';} function categoryBand(category){ctx.fillStyle='#e9f0fc';ctx.fillRect(margin,y,W-2*margin,CATEGORY_HEIGHT);ctx.fillStyle='#204b9e';ctx.font='bold 23px "Times New Roman", Times, serif';ctx.fillText(category,margin+10,y+29);y+=CATEGORY_HEIGHT;lastCategory=category;} page();for(const row of rows){ if(y+ROW_HEIGHT>footerY){finish();page();} ctx.fillStyle=rows.indexOf(row)%2===0?'#f4f7fb':'#ffffff';ctx.fillRect(margin,y,W-2*margin,ROW_HEIGHT); ctx.fillStyle='#20334d';ctx.font='bold 24px "Times New Roman", Times, serif';ctx.fillText(fitText(row.name,570,'bold 24px "Times New Roman", Times, serif'),margin+9,y+27); ctx.fillStyle='#64748b';ctx.font='18px "Times New Roman", Times, serif';ctx.fillText(fitText('QR: '+row.sku+' | Pozíció: '+row.position,670,'18px "Times New Roman", Times, serif'),margin+9,y+53); ctx.fillStyle='#52657d';ctx.font='19px "Times New Roman", Times, serif';ctx.fillText('Minimum: '+row.minimum+' | Hiány: '+row.shortage,margin+9,y+78); ctx.fillStyle='#20334d';ctx.textAlign='right';ctx.font='bold 24px "Times New Roman", Times, serif';ctx.fillText(row.quantity,W-margin-9,y+30);ctx.textAlign='left'; ctx.fillStyle='#dce5f0';ctx.fillRect(margin,y+ROW_HEIGHT-1,W-2*margin,1);y+=ROW_HEIGHT; } finish(); const encoder=new TextEncoder(),parts=[],offsets=[0];let length=0;const add=(data)=>{const b=typeof data==='string'?encoder.encode(data):data;parts.push(b);length+=b.length;}; add('%PDF-1.4\n');const count=2+pages.length*3;let obj=1;function object(id,content){offsets[id]=length;add(id+' 0 obj\n'+content+'\nendobj\n');} object(1,'<< /Type /Catalog /Pages 2 0 R >>');object(2,'<< /Type /Pages /Count '+pages.length+' /Kids ['+pages.map((_,i)=>(3+i*3)+' 0 R').join(' ')+'] >>'); pages.forEach((b64,i)=>{const pid=3+i*3,img=pid+1,content=pid+2;object(pid,'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /XObject << /Im'+i+' '+img+' 0 R >> >> /Contents '+content+' 0 R >>');const binary=atob(b64),bytes=new Uint8Array(binary.length);for(let k=0;k>\nstream\n');add(bytes);add('\nendstream\nendobj\n');const stream='q\n595 0 0 842 0 0 cm\n/Im'+i+' Do\nQ\n';object(content,'<< /Length '+encoder.encode(stream).length+' >>\nstream\n'+stream+'endstream');}); const xref=length;add('xref\n0 '+(count+1)+'\n0000000000 65535 f \n');for(let i=1;i<=count;i++)add(String(offsets[i]).padStart(10,'0')+' 00000 n \n');add('trailer\n<< /Size '+(count+1)+' /Root 1 0 R >>\nstartxref\n'+xref+'\n%%EOF'); const blob=new Blob(parts,{type:'application/pdf'}),url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download='villanystock-keszlethiany-'+now.toISOString().slice(0,10)+'.pdf';document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),30000); } function vsLastWorkMovement(){ const reversed=new Set(db.movements.filter(m=>m.reversalOf||m.reverses).map(m=>m.reversalOf||m.reverses)); return [...db.movements].reverse().find(m=>!m.reversedBy&&!reversed.has(m.id)&&!m.reversalOf&&!m.reverses&&( m.type==='box_transfer' || ((m.location||'RAKTAR')!=='RAKTAR'&&['job_use','job_return','scrap','out','measure'].includes(m.type)) )); } async function vsUndoLastWorkMovement(){ const m=vsLastWorkMovement(); if(!m)return alert('Nincs visszavonható munkavégzési készletmozgás.'); const p=db.products.find(x=>x.id===m.productId); if(!p||p.deletedAt)return alert('A termék nem aktív, a mozgás nem vonható vissza.'); if(m.jobId&&db.jobs.some(j=>j.id===m.jobId&&j.closedAt))return alert('Lezárt munkához tartozó anyagmozgás nem vonható vissza.'); const transfer=m.type==='box_transfer',amount=transfer?Number(m.quantity):Math.abs(Number(m.delta)); if(!Number.isFinite(amount)||amount<=0)return alert('Hibás mennyiségű mozgás.'); if(transfer){ const uniqueness=vsUniqueBoxTransferError(p,m.toLocation,m.fromLocation,amount);if(uniqueness)return alert('A visszavonás nem végezhető el: '+uniqueness); if(vsLocationQty(p,m.toLocation)+1e-80&&vsLocationQty(p,m.location||VS_WAREHOUSE)+1e-8

Utolsó készletmozgás visszavonása

A munkavégzés legutóbbi átcsoportosítását, anyagfelhasználását, visszavételét vagy selejtezését vonhatod vissza. A korábbi mozgás megmarad a naplóban.

`} function lastUndoableMovement(){ return [...db.movements].reverse().find(m=>!m.reversedBy&&!m.reversalOf&&m.productId&&['in','return','scrap','discard','out','job_use','measure'].includes(m.type)); } async function undoLastMovement(){ const m=lastUndoableMovement();if(!m)return alert('Nincs visszavonható készletmozgás.'); const p=db.products.find(x=>x.id===m.productId); if(!p||p.deletedAt)return alert('A termék már nem aktív. A visszavonás nem végezhető el.'); if(m.jobId&&db.jobs.some(j=>j.id===m.jobId&&j.closedAt))return alert('Lezárt munkához tartozó mozgás nem vonható vissza.'); if(m.delta>0&&qty(p)+1e-8!j.closedAt); return `

Gyors anyagkiadás munkához · ${esc(vsSelectedBox())}

Olvasd be a QR-kódot, válaszd ki a munkát, és add meg a mennyiséget.

${jobs.length?'':'

Előbb hozz létre egy folyamatban lévő munkát.

'}
`; } function quickJobLookup(){ const jid=document.getElementById('quick-job-select')?.value,code=document.getElementById('quick-job-code')?.value.trim(); const target=document.getElementById('quick-job-result');if(!target)return; const p=activeProducts().find(p=>String(p.sku).trim()===code); if(!p){target.innerHTML='

Nem található ilyen aktív termék.

';return} target.innerHTML=`

${esc(p.name)} · ${esc(vsSelectedBox())} készlet: ${fmtQty(vsLocationQty(p,vsSelectedBox()),p.unit)} ${esc(p.unit)}

`; } function openQuickJobScanner(){barcodeMode='quickjob';openBarcodeScanner()} async function quickJobIssue(){ const f=document.getElementById('quick-job-form'),jid=document.getElementById('quick-job-select')?.value; const p=db.products.find(x=>x.id===f?.elements.pid.value&&!x.deletedAt),j=db.jobs.find(x=>x.id===jid&&!x.closedAt); const amount=num(f?.elements.amount.value); if(!p||!j||!Number.isFinite(amount)||amount<=0||(p.unit==='db'&&!Number.isInteger(amount)))return alert('Ellenőrizd a munkát és a mennyiséget.'); if(amount>vsLocationQty(p,vsSelectedBox())+1e-8)return alert('Nincs elegendő készlet a kiválasztott MV dobozban.'); if(!await vsConfirm('Kiadod a munkához?\n'+p.name+' · '+fmtQty(amount,p.unit)+' '+p.unit+'\nMunka: '+j.name))return; db.movements.push({id:id(),at:stamp(),productId:p.id,delta:-amount,type:'out',jobId:j.id,location:vsSelectedBox(),priceAtUse:p.price||0,note:'Gyors QR-kódos anyagkiadás'}); save();view('dashboard');showScannedStock(p.sku); } function vsSearchText(value){return String(value||'').normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').toLocaleLowerCase('hu-HU').trim()} function dashboardProductSearch(){ const field=document.getElementById('dashboard-qr-code'),area=document.getElementById('dashboard-qr-result'); if(!field||!area)return; const raw=field.value.trim(),query=vsSearchText(raw); if(!query){area.innerHTML='

Adj meg egy QR-azonosítót vagy a termék nevének egy részét.

';return} const exact=activeProducts().find(p=>String(p.sku).trim()===raw); if(exact){vsWizardStart(exact.sku);return} if(vsShowBoxLookup(raw))return; const matches=activeProducts().filter(p=>vsSearchText(p.name).includes(query)); if(!matches.length){vsWizardStart(raw);return} if(matches.length===1){vsWizardStart(matches[0].sku);return} area.innerHTML='

Találatok ('+matches.length+')

Válaszd ki a keresett terméket.

'+matches.slice(0,60).map(p=>'').join('')+'
'+(matches.length>60?'

Az első 60 találat látható. Pontosítsd a keresést.

':'')+'
'; } function showScannedStock(code){code=String(code||'').trim();if(vsShowBoxLookup(code))return;if(!document.getElementById('dashboard-qr-result'))view('dashboard');const target=document.getElementById('dashboard-qr-code');if(target)target.value=code;const area=document.getElementById('dashboard-qr-result');if(!area)return;const product=db.products.find(p=>String(p.sku||'').trim()===code&&!p.deletedAt);if(!product){area.innerHTML='
Nem található aktív termék ezzel az azonosítóval: '+esc(code)+'. Ellenőrizd a kódot, vagy vedd fel az új terméket.
';return;}const q=warehouseQty(product),wire=categoryOf(product)==='wires';area.innerHTML='
Beolvasott termék

'+esc(product.name)+'

'+esc(categoryName(categoryOf(product)))+'

'+(wire?''+fmtMeters(q)+' m'+fmt(q*Number(product.gpm||0))+' g':''+fmtQty(q,product.unit)+' '+esc(product.unit)+'')+'

Azonosító: '+esc(product.sku)+'

'+(product.position?'

📍 Pozíció: '+esc(product.position)+'

':'')+'
';} function selectDashboardStockAction(type){const area=document.getElementById('dashboard-product-action'),code=document.getElementById('dashboard-qr-code')?.value.trim(),p=db.products.find(x=>x.sku===code&&!x.deletedAt);if(!area||!p)return;if(!['in','return','scrap','discard'].includes(type))return;const titles={in:'Bevételezés',return:'Visszavétel',scrap:'Selejtezés',discard:'Törlés a készletből'};area.innerHTML='

'+titles[type]+'

'+(p.unit==='m'?'':'')+''+(p.unit==='m'?'':'')+'
';dashboardStockPreview();} function dashboardStockPreview(){const form=document.getElementById('dashboard-stock-form'),preview=document.getElementById('dashboard-stock-conversion'),code=document.getElementById('dashboard-qr-code')?.value.trim(),p=db.products.find(x=>x.sku===code&&!x.deletedAt);if(!form||!preview||!p||!(p.gpm>0))return;const unit=form.elements.namedItem('inputUnit')?.value||'m',raw=num(form.elements.namedItem('qty')?.value||'');preview.textContent='Átváltás: '+(unit==='g'?(Number.isFinite(raw)&&raw>0?fmtMeters(raw/p.gpm)+' m':''):(Number.isFinite(raw)&&raw>0?fmt(raw*p.gpm)+' g':''))+' · '+fmt(p.gpm)+' g/m';} function vsJobManualWizard(jid,value){ const raw=String(value||'').trim(),area=document.getElementById('job-qr-result-'+jid); if(!raw){if(area)area.innerHTML='

Írd be a termék nevét vagy QR-azonosítóját.

';return;} const q=vsSearchText(raw),all=activeProducts(); const exact=all.find(p=>String(p.sku||'').trim().toLowerCase()===raw.toLowerCase()); if(exact){vsWizardStart(exact.sku,'job',jid);return;} const matches=all.filter(p=>vsSearchText(p.name).includes(q)); if(matches.length===1){vsWizardStart(matches[0].sku,'job',jid);return;} if(!area)return; if(!matches.length){area.innerHTML='

Nincs ilyen termék a nyilvántartásban. Ellenőrizd a nevet vagy azonosítót.

';return;} area.innerHTML='

Találatok ('+matches.length+')

Válassz terméket a közös műveleti felület megnyitásához.

'+matches.slice(0,60).map(p=>'').join('')+'
'; } function openJobBarcodeScanner(jid){barcodeJobId=jid;barcodeMode='job';openBarcodeScanner();} function openStockLookupScanner(){barcodeMode='lookup';openBarcodeScanner();} function openProductBarcodeScanner(){barcodeMode='product';openBarcodeScanner();} async function vsCameraOptions(selected){ const select=document.getElementById('barcode-camera-select');if(!select||!navigator.mediaDevices?.enumerateDevices)return; try{const devices=(await navigator.mediaDevices.enumerateDevices()).filter(d=>d.kind==='videoinput'); select.replaceChildren();const def=document.createElement('option');def.value='';def.textContent='Automatikus kamera';select.appendChild(def); devices.forEach((d,i)=>{const opt=document.createElement('option');opt.value=d.deviceId;opt.textContent=d.label||('Kamera '+(i+1));select.appendChild(opt)}); select.value=devices.some(d=>d.deviceId===selected)?selected:''; }catch(e){/* The camera picker is optional. */} } async function vsSwitchBarcodeCamera(deviceId){ const session=barcodeSession;const video=document.getElementById('barcode-video'); if(barcodeTimer){clearTimeout(barcodeTimer);barcodeTimer=null;} if(barcodeStream){barcodeStream.getTracks().forEach(t=>t.stop());barcodeStream=null;} barcodeBusy=false;barcodeTorchOn=false;barcodeTorchUI(false);barcodeStatus('Kamera váltása…'); try{const stream=await navigator.mediaDevices.getUserMedia({audio:false,video:deviceId?{deviceId:{exact:deviceId},width:{ideal:1280},height:{ideal:720}}:{facingMode:{ideal:'environment'},width:{ideal:1280},height:{ideal:720}}}); if(session!==barcodeSession){stream.getTracks().forEach(t=>t.stop());return;} barcodeStream=stream;video.srcObject=stream;await video.play();if(session!==barcodeSession)return; const track=stream.getVideoTracks()[0];const caps=typeof track?.getCapabilities==='function'?track.getCapabilities():{}; barcodeTorchUI(caps.torch===true);await vsCameraOptions(track.getSettings?.().deviceId||deviceId); barcodeStatus('Kamera aktív – irányítsd a QR- vagy vonalkódra.');scanBarcodeFrame(session); }catch(err){if(session===barcodeSession)barcodeStatus(err.name==='NotAllowedError'?'A kamera használatát engedélyezni kell a böngészőben.':'A kamera nem indítható: '+err.message);} } async function openBarcodeScanner(){vs164ScannerInstruction();const target=barcodeTarget();if(!target)return;closeBarcodeScanner();const modal=document.getElementById('barcode-scanner');modal.hidden=false;barcodeStatus('Kamera indítása…');const session=barcodeSession;const video=document.getElementById('barcode-video');if(!('BarcodeDetector' in window)){barcodeStatus('A böngésző nem támogatja a QR-felismerést. Válaszd a Manuális bevitel gombot a kód beírásához.');return;}try{barcodeDetector=new BarcodeDetector({formats:['qr_code','ean_13','ean_8','code_128','code_39','upc_a','upc_e','itf','data_matrix','pdf417']});}catch(e){try{barcodeDetector=new BarcodeDetector();}catch(err){barcodeStatus('A vonalkód-felismerés nem érhető el ebben a böngészőben.');return;}}if(!navigator.mediaDevices?.getUserMedia){barcodeStatus('A kamera csak HTTPS-en vagy localhoston érhető el. A content:// vagy file:// címen megnyitott HTML gyakran nem ad kamera-hozzáférést.');return;}await vsSwitchBarcodeCamera('');} async function scanBarcodeFrame(session){if(session!==barcodeSession||!barcodeStream||barcodeBusy)return;barcodeBusy=true;try{const video=document.getElementById('barcode-video');if(video?.readyState>=2){const found=await barcodeDetector.detect(video);if(session!==barcodeSession)return;if(found?.length&&found[0].rawValue){barcodeDetected(found[0].rawValue);return;}}}catch(err){if(session===barcodeSession)barcodeStatus('Beolvasás folyamatban. Tartsd stabilan a telefont, és legyen jó a megvilágítás.');}finally{barcodeBusy=false;}if(session===barcodeSession)barcodeTimer=setTimeout(()=>scanBarcodeFrame(session),180);} async function scanBarcodePhoto(input){const file=input.files?.[0];if(!file)return;const session=barcodeSession;if(!('BarcodeDetector' in window)){barcodeStatus('A böngésző nem támogatja a képfelismerést.');return;}try{const detector=barcodeDetector||new BarcodeDetector();const bitmap=await createImageBitmap(file);const found=await detector.detect(bitmap);bitmap.close?.();if(session!==barcodeSession)return;if(found?.length&&found[0].rawValue)barcodeDetected(found[0].rawValue);else barcodeStatus('A fényképen nem találtam felismerhető vonalkódot.');}catch(err){if(session===barcodeSession)barcodeStatus('Nem sikerült felismerni a kódot a képen.');}finally{input.value='';}} /* v4.26 – Offline-first change journal, per-device immutable revisions. This is a manual file transport, NOT an online Drive connection. */ const SYNC_KEY='villanystock-sync-v2',SYNC_TYPES=['products','jobs','movements','measurements','audit','reports']; let syncState; const syncClone=x=>JSON.parse(JSON.stringify(x)); function syncPersist(){localStorage.setItem(SYNC_KEY,JSON.stringify(syncState))} function syncInit(){ try{syncState=JSON.parse(localStorage.getItem(SYNC_KEY))}catch{} if(!syncState||syncState.protocol!==2||!syncState.deviceId)syncState={protocol:2,deviceId:id(),seq:0,records:{},changes:[],seen:{},lastSync:null,conflicts:[]}; for(const k of SYNC_TYPES)syncState.records[k]??={}; syncState.changes??=[];syncState.seen??={};syncState.conflicts??=[]; // An existing 4.25 database is a local baseline, not a proven shared baseline. for(const k of SYNC_TYPES)for(const item of db[k]||[]){ if(!item?.id||syncState.records[k][item.id])continue; const seq=++syncState.seq,rev=syncState.deviceId+':'+seq; syncState.records[k][item.id]={rev,hash:JSON.stringify(item)}; syncState.changes.push({key:k,recordId:item.id,rev,deviceId:syncState.deviceId,seq,at:new Date().toISOString(),value:syncClone(item),baseRev:null}); syncState.seen[rev]=true; } syncPersist(); } function syncCaptureChanges(){ if(!syncState)return; for(const k of SYNC_TYPES){ const actual=new Map((db[k]||[]).filter(x=>x?.id).map(x=>[x.id,x])); for(const [recordId,item] of actual){ const hash=JSON.stringify(item),old=syncState.records[k][recordId];if(old?.hash===hash&&!old.deleted)continue; const seq=++syncState.seq,rev=syncState.deviceId+':'+seq; syncState.records[k][recordId]={rev,hash}; syncState.changes.push({key:k,recordId,rev,deviceId:syncState.deviceId,seq,at:new Date().toISOString(),value:syncClone(item),baseRev:old?.rev||null}); syncState.seen[rev]=true; } for(const [recordId,old] of Object.entries(syncState.records[k])){ if(actual.has(recordId)||old.deleted)continue; const seq=++syncState.seq,rev=syncState.deviceId+':'+seq; syncState.records[k][recordId]={rev,hash:null,deleted:true}; syncState.changes.push({key:k,recordId,rev,deviceId:syncState.deviceId,seq,at:new Date().toISOString(),value:null,baseRev:old.rev}); syncState.seen[rev]=true; } } syncPersist(); } function syncDownload(name,content){ const url=URL.createObjectURL(new Blob([JSON.stringify(content,null,2)],{type:'application/json'})); const a=document.createElement('a');a.href=url;a.download=name;document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),30000); } function syncExport(){ syncCaptureChanges(); syncDownload('villanystock-szinkron-'+new Date().toISOString().slice(0,10)+'-'+syncState.deviceId.slice(0,8)+'.json',{ format:'villanystock-sync',version:2,exportedAt:new Date().toISOString(),source:syncState.deviceId,changes:syncState.changes }); } function syncValidChange(c){return c&&SYNC_TYPES.includes(c.key)&&typeof c.recordId==='string'&&c.recordId.length>0&&typeof c.rev==='string'&&typeof c.deviceId==='string'&&Number.isSafeInteger(c.seq)&&c.seq>0&&c.rev===c.deviceId+':'+c.seq&&(c.baseRev===null||typeof c.baseRev==='string')&&(c.value===null||(typeof c.value==='object'&&!Array.isArray(c.value)&&c.value.id===c.recordId));} async function syncImport(input){ if(input.size>20*1024*1024)throw Error('A szinkronfájl túl nagy (maximum 20 MB).'); const payload=JSON.parse(await input.text()); if(payload.format!=='villanystock-sync'||payload.version!==2||!Array.isArray(payload.changes))throw Error('Nem 4.26-os VillanyStock szinkronfájl.'); if(payload.changes.length>100000||payload.changes.some(c=>!syncValidChange(c)))throw Error('Hibás vagy túl nagy szinkronfájl.'); syncCaptureChanges(); const incoming=payload.changes.filter(c=>!syncState.seen[c.rev]&&c.deviceId!==syncState.deviceId); if(!incoming.length)return 0; // Apply each source's changes in sequence; accept only a matching base revision. // Distinct, newly created records merge without overwriting existing data. const updated=syncClone(db),metadata=syncClone(syncState),pending=[...incoming];let applied=0; let progress=true; while(progress&&pending.length){ progress=false; for(let i=0;ix.id===c.recordId); if(c.value===null){if(pos>=0)arr.splice(pos,1)} else if(pos>=0)arr[pos]=syncClone(c.value);else arr.push(syncClone(c.value)); metadata.records[c.key][c.recordId]={rev:c.rev,hash:c.value===null?null:JSON.stringify(c.value),deleted:c.value===null}; metadata.seen[c.rev]=true;metadata.changes.push(c);applied++;pending.splice(i,1);progress=true; } } if(pending.length){ const sample=pending.slice(0,3).map(c=>c.key+' / '+c.recordId).join('; '); throw Error(pending.length+' módosítás ütközik vagy hiányzik az előzménye ('+sample+'). Semmi nem lett importálva. Először exportáld a másik eszköz teljes szinkronnaplóját, vagy egyeztesd az ütközést.'); } metadata.lastSync=new Date().toISOString(); // Persist metadata first: after a crash, replayed changes remain deduplicated. localStorage.setItem(SYNC_KEY,JSON.stringify(metadata));localStorage.setItem(KEY,JSON.stringify(updated)); db=updated;syncState=metadata; return applied; } function syncPanel(){return `

Fájlos szinkronizálás (haladó)

Az egyes eszközök saját helyi adatbázist és változásnaplót tartanak fenn. A haladó fájlos szinkron külön, kézi funkció. A két eszköz között egyelőre JSON-fájllal vihetők át a módosítások.

Első használatkor mindkét eszközön azonos kiinduló adatállomány szükséges. A már egymástól függetlenül módosított adatbázisokat ne próbáld összevonni. Éles készletadatokkal még ne használd a szinkronfunkciót.

Eszköz: ${esc(syncState.deviceId.slice(0,12))}
Ismert változások: ${syncState.changes.length}
Utolsó import: ${syncState.lastSync?esc(journalDate(syncState.lastSync)):'Még nem volt'}

Import előtt teljes JSON biztonsági mentés javasolt. Ütközés esetén a teljes import megszakad.

`;} function syncWireInput(){document.getElementById('sync-file')?.addEventListener('change',async e=>{ try{if(!e.target.files?.[0])return;const n=await syncImport(e.target.files[0]);alert(n+' új változás átvéve.');view('backup')} catch(err){alert('Szinkronizálás sikertelen: '+err.message)} });} function journalDate(value){const d=new Date(value);return value&&!Number.isNaN(d.getTime())?d.toLocaleString('hu-HU'):'–';} function journalEntries(){ const entries=[]; const labels={undo:'Készletmozgás visszavonása',in:'Bevételezés',out:'Felhasználás',measure:'Vezetékmérés – felhasználás',receipt:'Bevételezés',return:'Visszavétel',scrap:'Selejtezés',discard:'Készletből törlés',job_use:'Munkaanyag felhasználása',job_cancel_reversal:'Munka törlése – jóváírás',product_edited:'Termék módosítása',adjust:'Készletkorrekció',product_deleted:'Termék törlése',product_restored:'Termék visszaállítása',product_purged:'Termék végleges törlése',job_deleted:'Munka törlése',job_material_used:'Anyagfelhasználás rögzítése',stock_scrapped:'Selejt rögzítése',journal_archived:'PDF-mentés és naplótörlés',job_closed:'Munka lezárása',job_paused:'Munka szüneteltetése',job_resumed:'Munka folytatása',job_started:'Munka indítása',job_created:'Munka létrehozása'}; const pname=(pid)=>db.products.find(p=>p.id===pid)?.name||'Korábban törölt termék'; const jname=(jid)=>db.jobs.find(j=>j.id===jid)?.name||''; for(const m of db.movements){ const p=db.products.find(x=>x.id===m.productId); const q=Number(m.delta),wire=p?.unit==='m'; const quantity=Number.isFinite(q)?(wire?fmtMeters(Math.abs(q))+' m':fmtQty(Math.abs(q),p?.unit||'db')+' '+(p?.unit||'db')):''; entries.push({at:m.at||'',kind:labels[m.type]||(q>=0?'Készlet növelése':'Készlet csökkentése'),name:pname(m.productId),quantity:(q>=0?'+':'−')+quantity,job:jname(m.jobId),note:m.note||'',key:'m'+m.id}); } // Product/job changes and other actions; movement-related audit events are already represented above. const movementAudit=new Set(['job_material_used','stock_scrapped','stock_received','stock_returned','stock_discarded','stock_adjusted']); for(const x of db.audit){ if(movementAudit.has(x.action))continue; entries.push({at:x.at||'',kind:labels[x.action]||String(x.action||'Egyéb tevékenység').replaceAll('_',' '),name:x.name||(x.productId?pname(x.productId):''),quantity:'',job:jname(x.jobId),note:x.reason||'',key:'a'+x.id}); } for(const j of db.jobs){ if(!db.audit.some(a=>a.action==='job_created'&&a.jobId===j.id))entries.push({at:j.createdAt||'',kind:'Munka létrehozása',name:j.name,quantity:'',job:'',note:'',key:'jc'+j.id}); if(j.closedAt&&!db.audit.some(a=>a.action==='job_closed'&&a.jobId===j.id))entries.push({at:j.closedAt,kind:'Munka lezárása',name:j.name,quantity:'',job:'',note:'',key:'jl'+j.id}); } // A napló archiválása csak a megjelenített naplót üríti; a készletmozgások és a munkák adatai megmaradnak. const cutoff=db.journalArchivedThrough||''; return entries.filter(e=>!cutoff||e.at>cutoff) .sort((x,y)=>y.at.localeCompare(x.at)||x.key.localeCompare(y.key)); } function journalTable(){ const rows=journalEntries(); return `

Tevékenységnapló

Az archivált napló PDF-ben megőrizhető; sikeres mentés után a korábbi sorok elrejthetők.

${rows.map(r=>``).join('')||''}
IdőpontTevékenységTermékMennyiségMegjegyzés
${esc(r.at?journalDate(r.at):'–')}${esc(r.kind)}${esc(r.name)}${esc(r.quantity)}${esc([r.job?('Munka: '+r.job):'',r.note].filter(Boolean).join(' · ')||'–')}
Még nincs naplózott tevékenység.
`; } function downloadJournalPDF(){ const entries=journalEntries(),W=1240,H=1754,margin=65,footer=1665,pages=[],now=new Date(); let canvas,ctx,y,pageNum=0; const columns=[margin+8,margin+170,margin+390,margin+615,margin+735],widths=[152,210,215,110,365]; function cut(t,w){t=String(t||'');while(t&&ctx.measureText(t).width>w)t=t.slice(0,-2)+'…';return t;} function page(){ canvas=document.createElement('canvas');canvas.width=W;canvas.height=H;ctx=canvas.getContext('2d');ctx.fillStyle='#fff';ctx.fillRect(0,0,W,H); pageNum++;y=margin;ctx.fillStyle='#183455';ctx.font='bold 26px "Times New Roman", Times, serif';ctx.fillText('VillanyStock – Tevékenységnapló',margin,y+26);y+=48; ctx.fillStyle='#64748b';ctx.font='22px "Times New Roman", Times, serif';ctx.fillText('Exportálva: '+now.toLocaleString('hu-HU')+' | Események: '+entries.length,margin,y+13);y+=36; ctx.fillStyle='#e9f0fc';ctx.fillRect(margin,y,W-2*margin,48);ctx.fillStyle='#204b9e';ctx.font='bold 22px "Times New Roman", Times, serif'; ['Időpont','Tevékenység','Termék','Mennyiség','Megjegyzés'].forEach((t,i)=>ctx.fillText(t,columns[i],y+31));y+=48; } function finish(){ctx.fillStyle='#e5eaf2';ctx.fillRect(margin,footer,W-2*margin,2);ctx.fillStyle='#64748b';ctx.font='19px "Times New Roman", Times, serif';ctx.fillText('VillanyStock · Napló export',margin,footer+23);ctx.textAlign='right';ctx.fillText('Oldal: '+pageNum,W-margin,footer+23);ctx.textAlign='left';pages.push(canvas.toDataURL('image/jpeg',0.85).split(',')[1]);} page(); function wrapCell(value,maxWidth,maxLines=3){ const words=String(value||'').split(/\s+/).filter(Boolean),lines=[];let line=''; for(const word of words){ const candidate=line?line+' '+word:word; if(ctx.measureText(candidate).width<=maxWidth){line=candidate;continue;} if(line){lines.push(line);line='';if(lines.length>=maxLines)break;} if(ctx.measureText(word).width<=maxWidth){line=word;continue;} let part='';for(const ch of word){if(ctx.measureText(part+ch).width>maxWidth&&part){lines.push(part);part='';if(lines.length>=maxLines)break;}part+=ch;} if(lines.length>=maxLines)break;line=part; } if(line&&lines.lengthmaxWidth)lines[maxLines-1]=cut(lines[maxLines-1],maxWidth); return lines.length?lines:['']; } for(const e of entries){ ctx.font='22px "Times New Roman", Times, serif'; const cells=[e.at?journalDate(e.at):'',e.kind,e.name,e.quantity,[e.job?('Munka: '+e.job):'',e.note].filter(Boolean).join(' · ')]; const wrapped=cells.map((v,i)=>wrapCell(v,widths[i],i===4?5:3)); const rowHeight=Math.max(50,15+Math.max(...wrapped.map(x=>x.length))*29); if(y+rowHeight+8>footer){finish();page();} if(entries.indexOf(e)%2===1){ctx.fillStyle='#f8faff';ctx.fillRect(margin,y,W-2*margin,rowHeight);} ctx.font='22px "Times New Roman", Times, serif';ctx.fillStyle='#24364c'; wrapped.forEach((lines,i)=>lines.forEach((line,k)=>ctx.fillText(line,columns[i],y+28+k*29))); ctx.fillStyle='#e8edf3';ctx.fillRect(margin,y+rowHeight-1,W-2*margin,1);y+=rowHeight; } finish(); const encoder=new TextEncoder(),parts=[],offsets=[0];let length=0; const add=(data)=>{const b=typeof data==='string'?encoder.encode(data):data;parts.push(b);length+=b.length;}; add('%PDF-1.4\n');const count=2+pages.length*3; function object(id,content){offsets[id]=length;add(id+' 0 obj\n'+content+'\nendobj\n');} object(1,'<< /Type /Catalog /Pages 2 0 R >>'); object(2,'<< /Type /Pages /Count '+pages.length+' /Kids ['+pages.map((_,i)=>(3+i*3)+' 0 R').join(' ')+'] >>'); pages.forEach((b64,i)=>{ const pid=3+i*3,img=pid+1,content=pid+2; object(pid,'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /XObject << /Im'+i+' '+img+' 0 R >> >> /Contents '+content+' 0 R >>'); const binary=atob(b64),bytes=new Uint8Array(binary.length);for(let k=0;k>\nstream\n');add(bytes);add('\nendstream\nendobj\n'); const stream='q\n595 0 0 842 0 0 cm\n/Im'+i+' Do\nQ\n'; object(content,'<< /Length '+encoder.encode(stream).length+' >>\nstream\n'+stream+'endstream'); }); const xref=length;add('xref\n0 '+(count+1)+'\n0000000000 65535 f \n');for(let i=1;i<=count;i++)add(String(offsets[i]).padStart(10,'0')+' 00000 n \n'); add('trailer\n<< /Size '+(count+1)+' /Root 1 0 R >>\nstartxref\n'+xref+'\n%%EOF'); const filename='villanystock-naplo-'+now.toISOString().slice(0,10)+'-'+now.toTimeString().slice(0,8).replaceAll(':','')+'.pdf'; const url=URL.createObjectURL(new Blob(parts,{type:'application/pdf'})),a=document.createElement('a');a.href=url;a.download=filename;document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),30000); return {filename, exportedAt:now.toISOString(), entryCount:entries.length}; } let vsJournalArchiveBusy=false; async function archiveJournalPDF(){ if(vsJournalArchiveBusy)return; const before=journalEntries(); if(!before.length)return alert('Nincs archiválható naplóbejegyzés.'); vsJournalArchiveBusy=true; try{ const result=downloadJournalPDF(); if(!result)return; if(!await vsConfirm('Sikeresen elmentetted a PDF-et?\n'+result.filename+'\n\nTörölhetők a korábbi naplóbejegyzések?'))return; // Ha közben új bejegyzés keletkezett, azt nem archiváljuk véletlenül. const latest=journalEntries(); if(latest.length!==before.length||latest.some((e,i)=>e.key!==before[i].key)){ return alert('Közben megváltozott a napló. Biztonsági okból nem ürítettük; készíts új PDF-et.'); } db.journalArchivedThrough=result.exportedAt; db.audit.push({id:id(),at:new Date(Date.now()+1).toISOString(),action:'journal_archived',reason:result.filename,name:'',productId:''}); save(); view('history'); }catch(e){alert('A napló archiválása nem sikerült: '+e.message)} finally{vsJournalArchiveBusy=false} } function scrapEntries(){return db.movements.filter(m=>m.type==='scrap'&&Number(m.delta)<0&&!m.scrapListDeletedAt).slice().reverse()} function scrapPanel(){const items=scrapEntries();return `

♻ Selejt ${items.length}

A selejtezett anyagok már levonódtak a használható készletből. Ez a lista csak megtekinthető, a rögzített selejtezéseket és indoklásukat mutatja.

${items.map(m=>{const p=db.products.find(x=>x.id===m.productId);const j=db.jobs.find(x=>x.id===m.jobId);return ``}).join('')||''}
DátumTermékMennyiségIndokMunka
${esc(journalDate(m.at))}${esc(p?.name||'Korábban törölt termék')}${esc(fmtQty(Math.abs(m.delta),p?.unit||'db'))} ${esc(p?.unit||'db')}${esc(m.note||'–')}${esc(j?.name||'–')}
Nincs nyilvántartott selejt.
`} async function removeScrapEntry(mid){return alert('A Raktár / Selejt nézet csak olvasható.');} async function vs1718LegacyRemoveScrapEntry(mid){const m=db.movements.find(x=>x.id===mid&&x.type==='scrap');if(!m)return;if(!await vsConfirm('Eltávolítod ezt a tételt a Selejt listából? A készlet és a tevékenységnapló nem változik.'))return;m.scrapListDeletedAt=stamp();save();view('scrap')} async function chooseBackupFolder(){if(!window.villanyBackup)return alert('A mappaválasztás a Windows-alkalmazásban használható.');try{const r=await window.villanyBackup.chooseFolder();if(r?.folder){await window.villanyBackup.save(JSON.stringify({version:1,data:db}));view('backup')}}catch(e){alert('Mappaválasztási hiba: '+e.message)}} async function restoreAutoBackup(){if(!window.villanyBackup)return;try{const result=await window.villanyBackup.restore();if(!result)return;const parsed=JSON.parse(result.text);if(!parsed.data||!['products','jobs','movements','measurements'].every(k=>Array.isArray(parsed.data[k])))throw Error('Hibás mentésfájl');if(!await vsConfirm('A kiválasztott mentés felülírja a jelenlegi készletadatokat. Folytatod?'))return;db=parsed.data;for(const k of Object.keys(initial()))db[k]??=[];db.reports??=[];save();view('stock');alert('Visszaállítás kész.')}catch(e){alert('Nem sikerült visszaállítani: '+e.message)}} async function showBackupFolder(){if(!window.villanyBackup)return;try{const folder=await window.villanyBackup.getFolder();const el=document.getElementById('auto-backup-folder');if(el)el.textContent=folder||'Még nincs kiválasztott mappa. A mentések a mappa kiválasztása után indulnak.'}catch(e){console.warn(e)}} /* VillanyStock 1.7.5 – extra tools. All HTML content is escaped. */ let vs2InventoryState=null,vs2RefillState=null; function vs2Modal(title,content){ let el=document.getElementById('vs2-tools-overlay'); if(!el){el=document.createElement('div');el.id='vs2-tools-overlay';el.className='vs-bw-overlay';document.body.appendChild(el)} el.innerHTML=''; } function vs2Close(){document.getElementById('vs2-tools-overlay')?.remove();vs2InventoryState=null;vs2RefillState=null} function vs2GlobalMatches(q){ const query=String(q||'').trim().toLocaleLowerCase('hu'); return activeProducts().filter(p=>{ const locations=vsBoxes().filter(b=>vsLocationQty(p,b)>0).join(' '); return !!query&&[p.name,p.sku,p.position,p.category,p.unit,p.gpm,p.price,warehouseQty(p),locations].some(v=>String(v??'').toLocaleLowerCase('hu').includes(query)); }).slice(0,100); } function vs2GlobalRows(q){ const query=String(q||'').trim();if(!query)return '

Keress terméket vagy munkadobozt név, azonosító vagy QR-kód alapján.

'; const items=vs2GlobalMatches(query); const products=items.map(p=>'').join(''); const boxes=vsBoxes().filter(b=>(b+' '+vsBoxLabel(b)+' '+String(db.boxQrCodes?.[b]||'')).toLocaleLowerCase('hu').includes(query.toLocaleLowerCase('hu'))).map(b=>'').join(''); return products+boxes||'

Nincs találat. QR-kód beolvasása után új terméket vagy munkadobozt is létrehozhatsz.

'; } let vs2GlobalLastQuery=''; function vs2GlobalSearch(q=''){ vs2GlobalLastQuery=String(q||''); vs2Modal('Globális kereső','
'+vs2GlobalRows(q)+'
'); const input=document.getElementById('vs2-global-input');if(input){input.value=vs2GlobalLastQuery;} if(!q)vs163OpenCamera('globallookup');else input?.focus() } function vs2GlobalScan(){barcodeMode='globallookup';openBarcodeScanner()} function vs2GlobalResults(q){ vs2GlobalLastQuery=String(q||'');const node=document.getElementById('vs2-global-results');if(node)node.innerHTML=vs2GlobalRows(q); } function vs2GlobalScrapQty(p){ // Selejtezés nem készlethely, hanem már levont mennyiség; a naplózott selejtet mutatjuk. return Math.max(0,db.movements.filter(m=>m.productId===p.id&&['scrap','job_scrap'].includes(m.type)).reduce((sum,m)=>sum-Number(m.delta||0),0)); } function vs2GlobalDetail(productId){ const p=activeProducts().find(item=>item.id===productId);if(!p)return alert('A termék már nem található.'); const locations=[[(p.position?'Raktár · '+p.position:'Raktár'),warehouseQty(p)],...vsBoxes().map(b=>[b,vsLocationQty(p,b)])]; const total=locations.reduce((sum,[,quantity])=>sum+Math.max(0,quantity),0); const scrap=vs2GlobalScrapQty(p); const lines=locations.filter(([,quantity])=>quantity>1e-8).map(([name,quantity])=>'
'+esc(name)+''+esc(fmtQty(quantity,p.unit))+' '+esc(p.unit)+'
').join('')||'

Jelenleg nincs raktári vagy munkadoboz-készlet.

'; const scrapLine='
Selejt (naplózott)'+esc(fmtQty(scrap,p.unit))+' '+esc(p.unit)+'
'; const price=Number(p.price||0); const content='
Megnevezés'+esc(p.name)+'
QR / termékazonosító'+esc(p.sku||'—')+'
Összes aktuális készlet'+esc(fmtQty(total,p.unit))+' '+esc(p.unit)+'
Egységár'+esc(fmtFt(price))+' Ft / '+esc(p.unit)+'
Összesített készletérték'+esc(fmtFt(total*price))+' Ft

Elhelyezkedés és mennyiségek

'+lines+scrapLine+'
'; vs2Modal('Termék részletei',content); } function vs2InventoryOpen(box=''){ if(!box){vs2Modal('Munkadoboz-leltár','

Válassz ellenőrizendő munkadobozt.

'+vsBoxes().map(b=>'').join('')+'
');return} if(!vsBoxes().includes(box))return alert('Nem létező munkadoboz.'); vs2InventoryState={box,checked:{}};vs2InventoryRender(); } function vs2InventoryScan(){barcodeMode='vs2inventory';openBarcodeScanner()} function vs2InventoryScanned(code){const state=vs2InventoryState;if(!state)return;const p=activeProducts().find(p=>String(p.sku).trim().toUpperCase()===String(code).trim().toUpperCase());if(!p||vsLocationQty(p,state.box)<=1e-8)return alert('Ez a termék nem szerepel a kijelölt dobozban: '+code);state.checked[p.id]=true;vs2InventoryRender()} function vs2InventoryToggle(pid,checked){if(!vs2InventoryState)return;vs2InventoryState.checked[pid]=checked;vs2InventoryRender()} function vs2InventoryRender(){ const state=vs2InventoryState;if(!state)return; const items=activeProducts().filter(p=>vsLocationQty(p,state.box)>1e-8); const count=items.filter(p=>state.checked[p.id]).length; const rows=items.map(p=>'').join('')||'

A doboz üres.

'; vs2Modal('Leltár · '+state.box,'

Ellenőrizve: '+count+'/'+items.length+'. Pipáld ki vagy olvasd be a fizikailag megtalált tételeket. Ez nem változtat készletet.

'+rows+'
'); } function vs2InventorySave(){ const state=vs2InventoryState;if(!state)return; const items=activeProducts().filter(p=>vsLocationQty(p,state.box)>1e-8); const missing=items.filter(p=>!state.checked[p.id]); db.audit??=[];db.audit.push({id:id(),at:new Date().toISOString(),action:'workbox_inventory_checked',box:state.box,checkedCount:items.length-missing.length,expectedCount:items.length,missing:missing.map(p=>({productId:p.id,name:p.name,quantity:vsLocationQty(p,state.box),unit:p.unit})),reason:'Fizikai munkadoboz-leltár'}); save();vs2Close();alert(missing.length?'Leltár mentve. Nem igazolt tételek: '+missing.length+'. A készlet változatlan.':'Leltár mentve, minden tétel igazolva.'); } function vs2ArchiveBulkStart(){ const a=(db.workBoxArchives||[]).find(x=>x.id===vsArchiveSelected); if(!a)return alert('Válassz archivált összeállítást.'); vsArchiveClose();vs2RefillState={archiveId:a.id,box:vsBoxes().includes(a.box)?a.box:(vsBoxes()[0]||''),quantities:Object.fromEntries((a.items||[]).map((it,i)=>[i,Number(it.quantity)||0]))};vs2ArchiveBulkRender(); } function vs2ArchiveBulkSetBox(box){if(!vs2RefillState)return;vs2RefillState.box=box;vs2ArchiveBulkRender()} function vs2ArchiveBulkQuantity(i,v){if(vs2RefillState)vs2RefillState.quantities[i]=v} function vs2ArchiveBulkRender(){ const w=vs2RefillState;if(!w)return; const a=(db.workBoxArchives||[]).find(x=>x.id===w.archiveId);if(!a)return; const boxButtons=vsBoxes().map(b=>'').join(''); const rows=(a.items||[]).map((it,i)=>{const p=activeProducts().find(p=>p.id===it.productId);return ''}).join(''); vs2Modal('Archivált doboz teljes újratöltése','

'+esc(a.box)+' · '+esc(a.jobName||'Korábbi összeállítás')+'

Válassz céldobozt és ellenőrizd a mennyiségeket. A nulla mennyiségű tételek kimaradnak.

'+boxButtons+'
'+rows+'
'); } async function vs2ArchiveBulkSave(){ const w=vs2RefillState,a=(db.workBoxArchives||[]).find(x=>x.id===w?.archiveId); if(!w||!a||!vsBoxes().includes(w.box))return alert('Válassz érvényes munkadobozt.'); const entries=[]; for(const [i,it] of (a.items||[]).entries()){ const q=Number(w.quantities[i]);if(!Number.isFinite(q)||q<0)return alert('Érvénytelen mennyiség: '+it.name); if(q===0)continue; const p=activeProducts().find(p=>p.id===it.productId);if(!p)return alert('Hiányzó termék: '+it.name); if(p.unit==='db'&&!Number.isInteger(q))return alert('Csak egész darabszám: '+it.name); if(q>warehouseQty(p)+1e-8)return alert('Nincs elegendő raktárkészlet: '+it.name); const conflict=vsUniqueBoxTransferError(p,VS_WAREHOUSE,w.box,q);if(conflict)return alert(it.name+': '+conflict); entries.push({id:id(),at:new Date().toISOString(),productId:p.id,type:'box_transfer',delta:0,quantity:q,fromLocation:VS_WAREHOUSE,toLocation:w.box,note:'Archivált munkadoboz teljes újratöltése · '+a.id}); } if(!entries.length)return alert('Nincs újratöltendő mennyiség.'); if(!await vsConfirm('Ellenőrizted és fizikailag behelyezted mind a(z) '+entries.length+' terméket a(z) '+w.box+' dobozba?'))return; const result=vsStockBatchCommit(entries);if(!result.ok)return alert(result.error); save();vs2Close();alert('Sikeres újratöltés: '+result.count+' tétel.');view('workbox'); } const VS_MANUAL_CHAPTERS=[ ['start','Első lépések','A VillanyStock három fő területe: Raktár, Munkavégzés és Általános. A felső menü ••• gombjával válthatsz területet.','Terület kiválasztása|Termékek felvétele|Készlet és munkák kezelése','Először vegyél fel egy próba-terméket és készíts JSON-mentést.'], ['product','Új termék felvétele','A Raktár → Készlet → + Új termék varázslóban add meg a QR-azonosítót, nevet, raktári pozíciót, kategóriát, minimumkészletet és egységárat. Vezetéknél a fajlagos tömeg (g/m) is szükséges.','Új termék|Azonosító és adatok|Ellenőrzés és mentés','Azonos QR-azonosítót ne adj két aktív terméknek. A termék felvétele önmagában nem jelent készletbevételezést.'], ['stock','Készlet és kategóriák','A Készlet fülön koppints egy kategóriára: a termékek közvetlenül alatta jelennek meg. A termék nevére koppintva megnyílik az adatlap, ahol bevételezhetsz, munkadobozba mozgathatsz, selejtezhetsz vagy indoklással törölhetsz.','Kategória lenyitása|Termék kiválasztása|Művelet és mentés','A piros X helyett a termék adatlapján található a naplózott törlés.'], ['wire','Vezetékek: méter és gramm','Vezetéknél a megadott fajlagos tömeg alapján a rendszer a grammot méterre, a métert grammra számítja át. Példa: 20 g/m esetén 150 g = 7,50 m. Az elérhető mennyiség a választott mértékegységben jelenik meg.','Vezeték kiválasztása|m vagy g megadása|Átváltás ellenőrzése|Mentés','A g/m értéket a tényleges vezeték alapján add meg; pontatlan érték hibás mennyiséget eredményez.'], ['boxes','Munkadobozok és áttekintés','A Munkavégzés → Munkadobozok alatt a QR-azonosítójú dobozokat kezelheted. Több dobozt kijelölhetsz, a Részletek megmutatja a tartalmukat. A termékeket a raktárból helyezd át.','Munkadoboz kiválasztása|Részletek|Áthelyezés|Mentés','A raktári és munkadobozos készletet külön helyen mutatja a rendszer.'], ['jobs','Munka indítása és lezárása','A Munka fülön indíts új munkát, válaszd ki a szükséges munkadobozokat, majd rögzítsd a felhasználást, visszavételt vagy selejtezést. A lezáráskor add meg a munkadíjat és utazási díjat, és készíts munkalapot.','Új munka|Munkadobozok|Anyagfelhasználás|Munka lezárása|Munkalap PDF','Munkához felhasznált anyagot a Munka felületen könyvelj, ne külön raktári kiadásként.'], ['archive','Archivált munkadobozok','A Munka terület felső menüjében nyisd meg az Archivált munkadobozok funkciót. Itt megtekintheted a korábbi dobozösszeállításokat, és az elérhető raktári készletből újratöltést indíthatsz.','Archívum megnyitása|Összeállítás kiválasztása|Készletellenőrzés|Újratöltés','Újratöltés előtt ellenőrizd, hogy minden tételből elegendő van-e a raktárban.'], ['search','Globális termékkereső','A főoldal tetején nyisd meg a globális keresőt. Az üres kereső nem sorol fel termékeket. Kezdj gépelni nevet, QR-kódot, pozíciót vagy más ismert adatot: a találatok azonnal szűkülnek. A találatra koppintva megnyílik a részletes adatlap.','Kereső megnyitása|Kifejezés beírása|Találat kiválasztása|Részletek','A részletekben a raktári, munkadobozos és naplózott selejtmennyiségek külön láthatók.'], ['scrap','Selejtezés és terméktörlés','A Selejtezés készletmozgás: a megadott mennyiség kikerül a felhasználható készletből. A Törlés az adatbázisból a teljes terméket inaktiválja, kötelező indoklással és megerősítéssel.','Termék adatlapja|Selejtezés vagy törlés|Indoklás|Megerősítés és mentés','A két művelet nem ugyanaz. Törlés előtt ellenőrizd a termékhez kapcsolódó aktív munkákat.'], ['trash','Lomtár, visszaállítás és végleges törlés','A termék adatlapján a Törlés az adatbázisból művelet után a termék 24 órára a Lomtárba kerül. Az Általános → Lomtár alatt megtekinthető a törlés oka és időpontja, valamint a hátralévő idő. A Visszaállítás visszateszi a terméket az aktív készletbe. A Lomtár ürítése az összes biztonságosan törölhető terméket végleg eltávolítja, 24 óra előtt is. A lejárt termékek a következő sikeres online bejelentkezéskor automatikusan törlődnek.','Termék törlése és indoklás|Általános → Lomtár|Visszaállítás vagy Lomtár ürítése|Végleges törlés','Végleges ürítés után nincs visszaállítás. Aktív munkához vagy készlettel rendelkező munkadobozhoz kapcsolódó termék biztonsági okból megmaradhat. A korábbi JSON-mentések külön fájlok.'], ['history','Napló és visszavonás','Az Általános → Napló alatt ellenőrizheted a rögzített műveleteket. Az utolsó készletmozgás visszavonása külön gombbal érhető el az érintett felületeken.','Napló megnyitása|Művelet megkeresése|Ellenőrzés','Visszavonás után ellenőrizd a készletet is.'], ['backup','Mentés, szinkron és RESET','Az Általános → Mentés fülön JSON-mentést exportálhatsz vagy importálhatsz. A Firebase-szinkron nem helyettesíti a biztonsági mentést. A RESET az összes felvitt adat törlésére szolgál, ezért csak tudatosan használd.','JSON export|Fájl megőrzése|Szükség esetén import','Frissítés, reset és fontos teszt előtt mindig ments. Ne töröld a böngésző tárolóját mentés nélkül.'] ]; function vsManualHtml(){return `

VillanyStock – Felhasználási útmutató

3.0 · Beépített, internet nélkül is olvasható útmutató. A folyamatábrák szemléltető példák, nem képernyőképek.

${vsManualChapters('')}
`} function vsManualChapters(query){const q=String(query||'').trim().toLocaleLowerCase('hu');const chapters=VS_MANUAL_CHAPTERS.filter(c=>!q||c.slice(1).join(' ').toLocaleLowerCase('hu').includes(q));return chapters.length?chapters.map(([id,title,description,steps,note],i)=>`
${esc(title)}

${esc(description)}

${steps.split('|').map((step,index)=>`
${index+1}${esc(step)}
`).join('')}

Fontos: ${esc(note)}

`).join(''):'

Nincs ilyen témájú fejezet.

'} function vsManualFilter(q){const el=document.getElementById('vs-manual-chapters');if(el)el.innerHTML=vsManualChapters(q)} // Raktár / Készlet: munkadobozonként lenyitható, csak olvasható készletlista. function vsWarehouseBoxContents(){ const boxes=vsBoxes(); const contents=boxes.map(code=>{ const products=activeProducts().filter(p=>{ const qty=vsLocationQty(p,code); return qty>0.000001 || db.movements.some(m=>m.productId===p.id&&(m.location===code||m.fromLocation===code||m.toLocation===code)); }).sort((a,b)=>a.name.localeCompare(b.name,'hu')); const rows=products.map(p=>{ const quantity=Math.max(0,vsLocationQty(p,code)); return `
${esc(p.name)}${fmtQty(quantity,p.unit)} ${esc(p.unit)}
`; }).join(''); return `
${esc(vsBoxLabel(code))}${products.length} termék
${rows||'

Ez a munkadoboz üres.

'}
`; }).join(''); return `

Munkadobozok tartalma

${contents||'

Még nincs munkadoboz.

'}
`; } function view(tab){if(tab==='products'){vsNewProductWizard();return;}if(tab==='move'){vsWizardChooseProduct();return;}if(!document.getElementById('barcode-scanner')?.hidden)closeBarcodeScanner();const targetSection=vsSectionForTab(tab);if(vsCurrentSection!==targetSection){vsCurrentSection=targetSection;vsSectionMenuOpen=false;}renderNav(tab);if(tab==='dashboard'){const value=activeProducts().reduce((a,p)=>a+warehouseQty(p)*(p.price||0),0),low=lowStockProducts(),empty=activeProducts().filter(p=>warehouseQty(p)<=0).length,activeJobs=db.jobs.filter(j=>!j.closedAt).length;box(`
📦
Nyilvántartott termékek
${activeProducts().length}
Különböző anyagfajta
💰
Készlet becsült értéke
${fmtFt(value)} Ft
Rögzített egységárak alapján
Elfogyott${empty}0 készletű termék
Beszerzendő${low.length}Minimum alatt / elfogyott
Aktív munkák${activeJobs}Folyamatban

Készlethiány

${low.length?low.slice(0,5).map(p=>`
${esc(p.name)}${esc(p.sku||'')}📍 ${esc(p.position||'Nincs megadva')}${fmtQty(warehouseQty(p),p.unit)} ${esc(p.unit)}
`).join(''):'

Nincs minimum alatti készlet.

'}
`)}else if(tab==='stock'){box(`

Készlet kategóriák szerint

Kattints egy anyagra a részletes adatok megtekintéséhez. Ez a felület csak olvasható; készletmódosítást a főoldali globális keresőből végezhetsz.

${categoryMenu()}
${vsWarehouseBoxContents()}`)}else if(tab==='scrap'){box(scrapPanel())}else if(tab==='products'){box(`

Új termék

Kézzel is megadhatod, vagy a kamera ikonnal beolvashatod.
${vsPositionEntry()}Betűvel kezdődik; betűk, számok, / és - használhatók. A nullák és az egymásra rakás jelölése opcionális. Később is módosítható.
`);document.getElementById('product').onsubmit=e=>{e.preventDefault();const f=new FormData(e.target);const sku=f.get('sku').trim();if(db.products.some(p=>!p.deletedAt&&String(p.sku||'').trim().toLowerCase()===sku.toLowerCase()))return alert('Ez az azonosító már létezik');const category=f.get('category'),unit=category==='wires'?'m':'db';const gpmInput=num(f.get('gpm')),minInput=num(f.get('min')),priceInput=num(f.get('price'));if(category==='wires'&&(!Number.isFinite(gpmInput)||gpmInput<=0))return alert('A vezetékhez adj meg pozitív g/m értéket.');if(!Number.isFinite(minInput)||minInput<0||!Number.isFinite(priceInput)||priceInput<0)return alert('Hibás minimumkészlet vagy egységár.');if(unit==='db'&&!Number.isInteger(minInput))return alert('Darabos terméknél a minimumkészlet egész szám legyen.');let position;try{position=normalizeStockPosition(f.get('position'))}catch(err){return alert(err.message)}if(vsRejectDuplicateProductName(f.get('name')))return;if(!vsCheckUniquePosition(position))return;const product={id:id(),sku,name:f.get('name').trim(),position,category,unit,gpm:category==='wires'?(num(f.get('gpm'))||0):0,min:num(f.get('min'))||0,price:num(f.get('price'))||0};db.products.push(product);stockCategory=category;save();view('stock')}}else if(tab==='move'){box(`

Anyagmozgás

Munkához anyagot kizárólag a Munkák menüben vagy a gyors anyagkiadással, munkadobozból lehet felhasználni.

`);document.getElementById('move').onsubmit=async e=>{e.preventDefault();const f=new FormData(e.target),p=db.products.find(p=>p.id===f.get('p')),q=num(f.get('q')),type=f.get('type'),delta=(type==='out'||type==='discard')?-q:q;if(!p||!Number.isFinite(q)||q<=0)return alert('Hibás mennyiség');if(delta<0&&vsLocationQty(p,VS_WAREHOUSE)+delta< -1e-8)return alert('Nincs elegendő raktári készlet');const note=String(f.get('note')||'').trim();if(type==='discard'&&!note)return alert('Törléshez kötelező az indoklás.');if(type==='discard'&&!await vsConfirm('Törlöd a készletből: '+fmt(q)+' '+p.unit+'?\nIndok: '+note))return;db.movements.push({id:id(),at:stamp(),productId:p.id,delta,type,jobId:'',location:VS_WAREHOUSE,note});if(type==='discard')recordAudit('stock_discarded',p,note,{delta});save();view('stock')}}else if(tab==='workbox'){box(vsWorkboxView());vsBoxActionHint()}else if(tab==='jobs'){box(jobsHtml());db.jobs.filter(j=>!j.closedAt).forEach(j=>updateLineUnit(j.id))}else if(tab==='history'){box(journalTable())}else if(tab==='trash'){box(vsTrashHtml())}else if(tab==='manual'){box(vsManualHtml())}else if(tab==='backup'){box(`${vsDriveBackupPanel()}

Automatikus biztonsági mentések

Válaszd ki a mappát, ahová minden adatváltozás után automatikus JSON-mentés készül. A legutóbbi 30 mentés marad meg.

Mentési mappa betöltése…

JSON biztonsági mentés

1. Kattints a JSON biztonsági mentésre, és őrizd meg a letöltött fájlt biztonságos helyen. 2. Szükség esetén válaszd ki a korábban elmentett JSON-fájlt, és importáld itt. Az importálás a teljes helyi adatállományt lecseréli. Mindig az aktuális eszköz legfrissebb mentését használd.

A Firebase szinkronizál, de nem helyettesíti a külön biztonsági mentést. Hetente és frissítés előtt tölts le JSON-mentést!

Adatok alaphelyzetbe állítása

Az összes általad rögzített termék, készlet, munka, munkadoboz, archívum és napló törlése. A bejelentkezési fiókod megmarad. Ez a Firebase-adatokat is felülírja, minden eszközön! Előbb tölts le JSON biztonsági mentést.

`);syncWireInput();vsDriveUpdatePanel();if(!window.villanyBackup){document.getElementById('desktop-auto-backup')?.remove()}else{showBackupFolder()}document.getElementById('export').onclick=()=>{const blob=new Blob([JSON.stringify({version:1,data:db},null,2)],{type:'application/json'}),u=URL.createObjectURL(blob),a=document.createElement('a');a.href=u;a.download='villanystock-mentes-'+new Date().toISOString().slice(0,10)+'.json';a.click();setTimeout(()=>URL.revokeObjectURL(u),1000)};document.getElementById('import').onchange=async e=>{try{if(!e.target.files?.length)return;const x=JSON.parse(await e.target.files[0].text());if(!x.data||!['products','jobs','movements','measurements'].every(k=>Array.isArray(x.data[k])))throw Error('Érvénytelen mentés');if(!await vsConfirm('A jelenlegi készletadatok felülíródnak. Folytatod?'))return;db=x.data;db.audit??=[];db.reports??=[];save();view('stock')}catch(err){alert('Sikertelen import: '+err.message)}}}} /* v1.1 – Explicit, owner-confirmed reset of user-entered application data. */ async function vsResetUserData(){ if(!vsReady||!vsUid||vsBlocked||vsSending||vsPending.length||!navigator.onLine){ alert('Az alaphelyzetbe állításhoz online, szinkronizált állapot szükséges. Várd meg a mentések befejezését, és oldd fel az esetleges ütközést.');return; } if(!await vsConfirm('FIGYELEM! Ez az összes saját feltöltött VillanyStock-adatot törli: termékeket, készletmozgásokat, munkákat, munkadobozokat, archívumot, naplót és munkalapokat. A törlés a Firebase-fiókodban is érvényesül, így más eszközökön is. Előtte készíts JSON-mentést!\n\nFolytatod?'))return; const answer=await vsPrompt('A végleges törlés megerősítéséhez írd be pontosan: TÖRLÉS'); if(answer!=='TÖRLÉS'){if(answer!==null)alert('A törlés megszakadt: a megerősítő szöveg nem egyezik.');return;} if(!vsReady||!vsUid||vsBlocked||vsSending||vsPending.length||!navigator.onLine){alert('Az állapot megváltozott, a törlés megszakadt.');return;} const fresh={...initial(),reports:[],workBoxes:[],boxArchives:[],workBoxArchives:[]}; // Keep the account and its authentication; replace the entire application dataset. db=fresh; localStorage.removeItem('vs-selected-workbox');localStorage.removeItem('vs-active-workboxes'); if(typeof syncState!=='undefined'&&syncState){ syncState={protocol:2,deviceId:id(),seq:0,records:{},changes:[],seen:{},lastSync:null,conflicts:[]}; for(const k of SYNC_TYPES)syncState.records[k]={};syncPersist(); } save(); vsCloudStatus('☁ Teljes adattörlés mentése folyamatban…'); view('backup'); alert('A helyi adatok alaphelyzetbe kerültek. Várd meg, amíg a felhőmentés befejeződik. A korábban letöltött JSON-mentések külön megmaradnak.'); } const VS_FIREBASE_CONFIG = { apiKey: "AIzaSyCqBqv9OkUvaqFd8e9ul6-ijrZOKszLcAA", authDomain: "villanystock.firebaseapp.com", projectId: "villanystock", storageBucket: "villanystock.firebasestorage.app", messagingSenderId: "740675949860", appId: "1:740675949860:web:b0e23f4b5649d8927e0fb1" }; /* v4.48: single-owner Firebase Authentication + revision-checked Firestore sync. No access to legacy Google Drive; fresh data namespace. */ let vsAuth=null,vsStore=null,vsUid=null,vsRevision=0,vsReady=false,vsBlocked=false; let vsPending=[],vsSending=false,vsUnsubscribe=null,vsDoc=null,vsAuthStarting=false; const vsGate=document.getElementById('vs-auth-gate'); // v1.5.6: offline access only after this device has successfully authenticated online. // Browser storage is not encrypted; do not use offline access on shared devices. const VS_OFFLINE_OWNER='vs156-offline-owner'; let vsOfflineSession=false,vsOfflineRecovering=false; function vsOfflineOwner(){try{const o=JSON.parse(localStorage.getItem(VS_OFFLINE_OWNER)||'null');return o&&typeof o.uid==='string'&&o.uid&&o.enabled?o:null}catch{return null}} function vsRememberOffline(){ if(!vsReady||!vsUid||vsBlocked)return; const o=vsOfflineOwner(); if(!vsOfflineSession || !o || o.uid!==vsUid){ if(vsOfflineSession&&o&&o.uid!==vsUid)return; localStorage.setItem(VS_OFFLINE_OWNER,JSON.stringify({uid:vsUid,enabled:true,at:Date.now(),revision:vsRevision})); } } function vsOfflineAvailable(){ const o=vsOfflineOwner();if(!o)return false; try{const d=JSON.parse(localStorage.getItem(KEY));return vsValidate(d)}catch{return false} } function vsOfflineButton(){ const btn=document.getElementById('vs-offline-login'),hint=document.getElementById('vs-offline-hint'); if(!btn)return; const available=vsOfflineAvailable();btn.style.display=available?'block':'none'; if(hint)hint.textContent=available?'Korábban ezen az eszközön már bejelentkeztél. A helyi készlet offline elérhető.':'Offline használathoz először egyszer sikeresen be kell jelentkezni internetkapcsolattal ezen az eszközön.'; } function vsOfflineLogin(){ if(!vsOfflineAvailable()){vsOfflineButton();return;} const o=vsOfflineOwner(); if(vsReady)return; try{ const d=JSON.parse(localStorage.getItem(KEY));if(!vsValidate(d))throw Error('Érvénytelen helyi adatok'); db=d;db.audit??=[];db.reports??=[]; vsUid=o.uid;vsOfflineSession=true;vsReady=true;vsBlocked=false; let q=null;try{q=JSON.parse(localStorage.getItem('vs48-pending-'+vsUid))}catch{} vsPending=Array.isArray(q?.items)?q.items:[]; vsRevision=Number(q?.base ?? o.revision)||0; vsGate.hidden=true;document.getElementById('vs-app-shell').hidden=false; vsSectionHome(); vsCloudStatus('📴 Offline mód – '+vsPending.length+' módosítás feltöltésre vár.'); if(typeof vsDriveHideDialog==='function')vsDriveHideDialog(); if(typeof vsDriveBannerHide==='function')vsDriveBannerHide(); }catch(e){document.getElementById('vs-auth-error').textContent='Offline megnyitás sikertelen: '+e.message;} } async function vsOfflineReconnect(){ if(!vsOfflineSession||!vsReady||!navigator.onLine||vsOfflineRecovering)return; if(!vsAuth?.currentUser){vsCloudStatus('⚠ Internet visszatért – jelentkezz be online a szinkronizációhoz. A helyi munka megmarad.');return;} if(vsAuth.currentUser.uid!==vsUid){vsCloudStatus('⚠ Másik fiók van bejelentkezve. A helyi módosítások megőrizve; szinkronizáció leállítva.');return;} vsOfflineRecovering=true; try{await vsLoadCloud();vsOfflineSession=false;vsCloudStatus(vsBlocked?'⚠ Szinkronütközés – helyi adatok megőrizve.':'☁ Online kapcsolat helyreállt – szinkronizáció elindult.');} catch(e){vsCloudStatus('⚠ A felhő nem érhető el: '+e.message+' – offline munkavégzés folytatható.');vsReady=true;} finally{vsOfflineRecovering=false;} } window.addEventListener('offline',()=>{if(!vsReady)vsOfflineButton();}); window.addEventListener('online',()=>{if(vsOfflineSession)vsOfflineReconnect();}); setTimeout(vsOfflineButton,0); function vsCloudStatus(message){const e=document.getElementById('vs-cloud-status');e.hidden=false;e.textContent=message;const p=document.getElementById('vs-cloud-panel');if(p)p.textContent=message;} function vsValidate(data){return data&&['products','jobs','movements','measurements'].every(k=>Array.isArray(data[k]));} function vsCloudQueueSave(){ if(!vsReady||!vsUid||vsBlocked)return; vsPending.push(JSON.parse(JSON.stringify(db))); localStorage.setItem('vs48-pending-'+vsUid,JSON.stringify({base:vsRevision,items:vsPending})); vsCloudStatus(navigator.onLine?'☁ Mentés folyamatban ('+vsPending.length+')…':'⚠ Offline – '+vsPending.length+' módosítás feltöltésre vár'); vsFlush(); } async function vsFlush(){ if(vsSending||vsBlocked||!vsReady||!vsPending.length||!navigator.onLine)return; vsSending=true; try{ while(vsPending.length&&!vsBlocked&&navigator.onLine){ const snapshot=vsPending[0],base=vsRevision; const next=await vsStore.runTransaction(async tx=>{ const doc=await tx.get(vsDoc),remote=doc.exists?doc.data():null; const actual=remote?.revision||0; if(actual!==base)throw new Error('VS_CONFLICT'); tx.set(vsDoc,{revision:base+1,data:snapshot,updatedAt:firebase.firestore.FieldValue.serverTimestamp(),ownerUid:vsUid}); return base+1; }); vsRevision=next;vsPending.shift();if(typeof vsRememberOffline==='function')vsRememberOffline(); if(vsPending.length)localStorage.setItem('vs48-pending-'+vsUid,JSON.stringify({base:vsRevision,items:vsPending})); else localStorage.removeItem('vs48-pending-'+vsUid); } if(!vsPending.length)vsCloudStatus('☁ Minden módosítás mentve'); }catch(e){ if(e.message==='VS_CONFLICT'){ vsBlocked=true; vsCloudStatus('⚠ Ütközés! A helyi módosítások nincsenek feltöltve. Készíts JSON-mentést; ne módosíts tovább.'); alert('Egy másik eszköz közben módosította az adatokat. A VillanyStock megállította a mentést, hogy ne írja felül a változásokat. Készíts JSON-mentést a Mentés menüben, majd jelentkezz ki és be a friss felhőadatokért.'); }else vsCloudStatus('⚠ Mentés várakozik: '+e.message); }finally{vsSending=false;} } // 2.9: A lejárt, naplózott terméktörlések takarítása kizárólag // sikeres online felhőbetöltés után, szinkronütközés nélkül történik. const VS_PRODUCT_RETENTION_MS=24*60*60*1000; function vsExpiredProductIds(data,now=Date.now()){ const products=Array.isArray(data?.products)?data.products:[]; return products.filter(p=>{ if(!p.deletedAt)return false; const at=Date.parse(p.deletedAt); if(!Number.isFinite(at)||at>now-VS_PRODUCT_RETENTION_MS)return false; // Aktív munkában vagy aktív dobozban szereplő tétel nem törölhető. if((data.jobs||[]).some(j=>!j.closedAt&&( (j.lines||[]).some(l=>l.productId===p.id||l.pid===p.id)|| (j.items||[]).some(l=>l.productId===p.id||l.pid===p.id)|| (data.movements||[]).some(m=>m.productId===p.id&&m.jobId===j.id) )))return false; if((data.workBoxes||[]).some(box=>{ const name=typeof box==='string'?box:box?.name||box?.id; return (data.movements||[]).filter(m=>m.productId===p.id&&(m.location===name||m.to===name||m.from===name)).reduce((sum,m)=>sum+Number(m.delta||0),0)>1e-8; }))return false; return true; }).map(p=>p.id); } function vsPurgeExpiredProducts(data,now=Date.now(),specificIds=null){ const ids=new Set(specificIds===null?vsExpiredProductIds(data,now):[...specificIds].filter(pid=>{const p=data.products.find(x=>x.id===pid);return p&&p.deletedAt&&vsCanPurgeProduct(data,p)})); if(!ids.size)return 0; data.products=data.products.filter(p=>!ids.has(p.id)); // A lezárt munkákhoz kapcsolódó készletmozgások és az auditnyom megmaradnak. const jobIds=new Set((data.jobs||[]).map(j=>j.id)); data.movements=(data.movements||[]).filter(m=>!ids.has(m.productId)||jobIds.has(m.jobId)); data.measurements=(data.measurements||[]).filter(m=>!ids.has(m.productId)); for(const a of data.audit||[]){if(ids.has(a.productId)){ delete a.snapshot; // a teljes termékobjektum ne foglaljon helyet a naplóban if(a.action==='product_deleted')a.purgedAt=new Date(now).toISOString(); }} return ids.size; } function vsTrashHtml(){ const deleted=db.products.filter(p=>p.deletedAt).sort((a,b)=>Date.parse(b.deletedAt)-Date.parse(a.deletedAt)); const rows=deleted.map(p=>{ const ms=Date.parse(p.deletedAt)+VS_PRODUCT_RETENTION_MS-Date.now(); const remaining=ms>0?Math.ceil(ms/3600000)+' óra':'Lejárt – online belépéskor törlődik'; const entry=(db.audit||[]).filter(a=>a.productId===p.id&&a.action==='product_deleted').at(-1); return `
${esc(p.name)}

Azonosító: ${esc(p.sku||'—')}
Törlés: ${esc(new Date(p.deletedAt).toLocaleString('hu-HU'))}
Indok: ${esc(entry?.reason||'—')}
Hátralévő idő: ${esc(remaining)}

${ms>0?``:''}
`; }).join(''); return `

♻ Lomtár

A törölt termékek 24 óráig visszaállíthatók. Ezután a következő sikeres online bejelentkezéskor végleg törlődnek. Az ürítés azonnali és nem visszavonható; a szükséges napló- és munkatörténeti adatok megmaradnak.

${deleted.length} termék a Lomtárban

${rows||'

A Lomtár üres.

'}${deleted.length?'':''}
`; } async function vsEmptyTrash(){ const all=db.products.filter(p=>p.deletedAt);if(!all.length)return; if(!navigator.onLine||!vsReady||!vsUid||vsBlocked||vsPending.length||vsSending){alert('A Lomtár végleges ürítéséhez sikeres online bejelentkezés és befejezett szinkronizáció szükséges.');return;} const eligible=new Set(all.map(p=>p.id)); const blocked=new Set(all.filter(p=>!vsCanPurgeProduct(db,p)).map(p=>p.id)); for(const id of blocked)eligible.delete(id); if(!eligible.size){alert('A Lomtárban lévő termékek aktív munkához vagy munkadoboz-készlethez kapcsolódnak, ezért nem törölhetők véglegesen.');return;} if(!await vsConfirm('Végleg törlöd a Lomtár '+eligible.size+' termékét?\nEz nem vonható vissza. '+(blocked.size?'\n'+blocked.size+' kapcsolódó termék biztonsági okból megmarad.':'')))return; if(!navigator.onLine||vsBlocked||vsPending.length||vsSending){alert('A szinkronizáció állapota megváltozott. Próbáld újra.');return;} const count=vsPurgeExpiredProducts(db,Date.now(),eligible);if(count){save();view('trash');alert(count+' termék végleges törlése elindult. A felhőbe mentés folyamatban lehet.');} } function vsCanPurgeProduct(data,p){ const now=Date.now(),original=p.deletedAt; try{p.deletedAt=new Date(now-VS_PRODUCT_RETENTION_MS-1000).toISOString();return vsExpiredProductIds(data,now).includes(p.id)}finally{p.deletedAt=original} } function vsPurgeAfterLogin(){ if(!vsReady||!vsUid||vsBlocked||vsPending.length||vsSending||!navigator.onLine)return 0; const count=vsPurgeExpiredProducts(db); if(count){save();vsCloudStatus('☁ '+count+' lejárt termék végleges törlése szinkronizálás alatt…');} return count; } async function vsLoadCloud(){ vsReady=false;vsPending=[];vsBlocked=false;vsRevision=0; vsDoc=vsStore.collection('villanystock').doc('owner-data'); const snap=await vsWithTimeout(vsDoc.get(),18000,'A felhőadatok betöltése túl sokáig tart. Ellenőrizd az internetkapcsolatot, majd próbáld újra. A helyi adatok megmaradtak.');const remote=snap.exists?snap.data():null; if(remote&& !vsValidate(remote.data))throw new Error('A felhőben érvénytelen adat található.'); db=remote?remote.data:initial();db.audit??=[];db.reports??=[]; vsRevision=remote?.revision||0; let pendingBackup=null;try{pendingBackup=JSON.parse(localStorage.getItem('vs48-pending-'+vsUid))}catch{} if(pendingBackup?.items?.length){ vsPending=pendingBackup.items;db=vsPending[vsPending.length-1]; if(pendingBackup.base!==vsRevision){vsBlocked=true;} } localStorage.setItem(KEY,JSON.stringify(db)); vsReady=true;vsRememberOffline();vsOfflineSession=false;vsSectionHome();vsGate.hidden=true;document.getElementById('vs-app-shell').hidden=false; setTimeout(()=>{if(vsReady&&typeof vsDriveAfterLogin==='function')vsDriveAfterLogin();},200); vsCloudStatus(vsBlocked?'⚠ Korábbi, fel nem töltött módosítások ütköznek. Készíts JSON-mentést!':vsPending.length?'☁ Korábbi függőben lévő mentések folytatása…':remote?'☁ Felhőadatok betöltve':'☁ Új, üres készlet – automatikus mentés aktív'); if(vsPending.length&&!vsBlocked)vsFlush(); else if(!vsBlocked)vsPurgeAfterLogin(); if(vsUnsubscribe)vsUnsubscribe(); vsUnsubscribe=vsDoc.onSnapshot(s=>{ if(!vsReady||!s.exists||s.metadata.hasPendingWrites)return; const r=s.data();if(!r||r.revision<=vsRevision)return; if(vsSending&&r.revision===vsRevision+1)return; if(vsPending.length||vsSending){vsBlocked=true;vsCloudStatus('⚠ Másik eszköz módosított. A helyi módosítások megőrizve, kézi egyeztetés szükséges.');return;} if(!vsValidate(r.data))return; db=r.data;db.audit??=[];db.reports??=[];vsRevision=r.revision; localStorage.setItem(KEY,JSON.stringify(db)); const tab=document.querySelector('.menu .active')?.dataset?.tab||'dashboard'; view(tab);vsCloudStatus('☁ Frissítve egy másik eszközről'); },e=>vsCloudStatus('⚠ Figyelés sikertelen: '+e.message)); } async function vsRequestLogout(){ if(!(await vsConfirm("Biztosan ki szeretnél jelentkezni a VillanyStockból?")))return; try{await vsLogout();}catch(e){alert("A kijelentkezés nem sikerült: "+e.message);} } async function vsLogout(){ if(typeof vsDriveOnLogout==='function')vsDriveOnLogout(); if(vsPending.length||vsBlocked){alert('Függőben lévő vagy ütköző módosítások vannak. Előbb készíts JSON-mentést!');return;} if(vsUnsubscribe)vsUnsubscribe();vsUnsubscribe=null;vsReady=false;vsUid=null; db=initial();localStorage.removeItem(KEY);localStorage.removeItem(VS_OFFLINE_OWNER);vsOfflineSession=false;document.getElementById('vs-app-shell').hidden=true;vsGate.hidden=false; await vsAuth.signOut(); } function vsWithTimeout(p,ms,message){return Promise.race([p,new Promise((_,reject)=>setTimeout(()=>reject(new Error(message)),ms))]);} function vsAuthRetry(){if(!vsAuth)return location.reload();const u=vsAuth.currentUser;if(!u){document.getElementById('vs-auth-error').textContent='Add meg az e-mail-címet és jelszót, majd jelentkezz be.';return;}if(vsAuthStarting)return;vsAuthStarting=true;document.getElementById('vs-auth-error').textContent='Felhőadatok újrapróbálása…';vsUid=u.uid;vsLoadCloud().catch(e=>{document.getElementById('vs-auth-error').textContent=e.message;}).finally(()=>{vsAuthStarting=false;});} async function vsFirebaseStart(){ const err=document.getElementById('vs-auth-error'); err.textContent='Korábbi bejelentkezés ellenőrzése…'; if(!window.firebase){err.textContent='A Firebase nem töltődött be. Internet nélkül válaszd az offline folytatást.';vsOfflineButton();return;} try{ firebase.initializeApp(VS_FIREBASE_CONFIG);vsAuth=firebase.auth();vsStore=firebase.firestore(); await vsWithTimeout(vsAuth.setPersistence(firebase.auth.Auth.Persistence.LOCAL),12000,'A bejelentkezés inicializálása nem sikerült időben.'); document.getElementById('vs-auth-form').addEventListener('submit',async e=>{ e.preventDefault();err.textContent='Bejelentkezés…'; try{await vsWithTimeout(vsAuth.signInWithEmailAndPassword(document.getElementById('vs-email').value.trim(),document.getElementById('vs-password').value),18000,'A bejelentkezés időtúllépés miatt megszakadt.');document.getElementById('vs-password').value='';} catch(ex){err.textContent='Sikertelen bejelentkezés: '+(ex.message||'Ellenőrizd a kapcsolatot és a belépési adatokat.');} }); setTimeout(()=>{if(!vsReady&&!vsAuthStarting&&vsAuth?.currentUser===undefined){err.textContent='A hitelesítés nem válaszol. Próbáld újra a kapcsolódást.';}},20000); vsAuth.onAuthStateChanged(async user=>{ if(!user){if(vsOfflineSession)return;vsReady=false;vsUid=null;vsGate.hidden=false;document.getElementById('vs-app-shell').hidden=true;err.textContent='';vsOfflineButton();return;} if(vsAuthStarting)return;if(vsOfflineSession){if(user.uid===vsUid&&navigator.onLine)vsOfflineReconnect();return;}vsAuthStarting=true;vsUid=user.uid; err.textContent='Felhőadatok betöltése…'; try{await vsLoadCloud();}catch(e){err.textContent='Nem sikerült betölteni az adatokat: '+e.message;vsReady=false;vsGate.hidden=false;vsOfflineButton();} finally{vsAuthStarting=false;} }); window.addEventListener('online',()=>{if(vsOfflineSession){vsOfflineReconnect();return;}vsCloudStatus('☁ Kapcsolat helyreállt – szinkronizálás…');vsFlush();}); window.addEventListener('offline',()=>vsCloudStatus('⚠ Offline – módosítások helyben tárolva')); }catch(e){err.textContent='Firebase hiba: '+e.message;} } document.getElementById('vs-auth-error').insertAdjacentHTML('afterend',''); vsFirebaseStart();if('serviceWorker'in navigator&&['https:','http:'].includes(location.protocol))navigator.serviceWorker.register('./sw.js').catch(()=>{});